mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 13:36:53 +00:00
Merge main into fix/followup-6841
This commit is contained in:
@@ -187,6 +187,9 @@ pub struct HpglOutput {
|
||||
pub pens: Vec<[u8; 3]>,
|
||||
/// What was approximated or left out.
|
||||
pub warnings: Vec<String>,
|
||||
/// Notes on the options: sorting the paths or drawing the inner paths first was applied only
|
||||
/// in part.
|
||||
pub setting_notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Write the visible art of `doc` over `opts.region` as an HP-GL plot. Live geometry effects are
|
||||
@@ -280,10 +283,11 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R
|
||||
if !by_pen.iter().all(Vec::is_empty) {
|
||||
pen_warnings.iter().for_each(|w| warn(&mut warnings, w));
|
||||
}
|
||||
let mut setting_notes = vec![];
|
||||
if opts.sort || opts.inside_first {
|
||||
let (mut at, mut work) = ((0, 0), MAX_SORT_WORK);
|
||||
for paths in &mut by_pen {
|
||||
at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings);
|
||||
at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut setting_notes);
|
||||
}
|
||||
}
|
||||
if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) {
|
||||
@@ -293,7 +297,7 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R
|
||||
Version::Hpgl => plain(&by_pen),
|
||||
Version::Hpgl2 => encoded(&by_pen, &pens),
|
||||
};
|
||||
out.push(HpglOutput { bytes, pens: pens.clone(), warnings });
|
||||
out.push(HpglOutput { bytes, pens: pens.clone(), warnings, setting_notes });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -366,20 +370,20 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 {
|
||||
/// for a closed path any of its points (where it then starts); without, the first in paint order.
|
||||
/// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is.
|
||||
/// Once `work` is spent, the paths left keep paint order, each after the paths inside it with
|
||||
/// `inside_first`, with a warning. Returns where the pen stops.
|
||||
/// `inside_first`, with a note in `notes`. Returns where the pen stops.
|
||||
fn reorder(
|
||||
paths: &mut Vec<Vec<(i64, i64)>>,
|
||||
mut at: (i64, i64),
|
||||
work: &mut u64,
|
||||
nearest: bool,
|
||||
inside_first: bool,
|
||||
warnings: &mut Vec<String>,
|
||||
notes: &mut Vec<String>,
|
||||
) -> (i64, i64) {
|
||||
let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect();
|
||||
// The paths each path lies inside, and how many paths inside each are still to draw.
|
||||
let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten();
|
||||
if inside_first && outer.is_none() {
|
||||
warn(warnings, UNNESTED);
|
||||
warn(notes, UNNESTED);
|
||||
}
|
||||
let mut waiting = vec![0usize; paths.len()];
|
||||
for o in outer.iter().flatten().flatten() {
|
||||
@@ -430,11 +434,11 @@ fn reorder(
|
||||
if *work == 0 || best.0 >= left.len() {
|
||||
match &outer {
|
||||
Some(outer) => {
|
||||
warn(warnings, UNSORTED_INSIDE);
|
||||
warn(notes, UNSORTED_INSIDE);
|
||||
inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths);
|
||||
}
|
||||
None => {
|
||||
warn(warnings, UNSORTED);
|
||||
warn(notes, UNSORTED);
|
||||
paths.extend(left.into_iter().map(|(_, p, _)| p));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,7 +852,8 @@ fn sorting_measures_a_closed_path_by_its_nearest_point() {
|
||||
fn sorting_stops_at_its_budget_and_keeps_every_path() {
|
||||
let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect());
|
||||
let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) });
|
||||
assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings);
|
||||
assert_eq!(out.setting_notes, [UNSORTED]);
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
assert_eq!(text(&out).matches("PD").count(), 600);
|
||||
}
|
||||
|
||||
@@ -944,18 +945,18 @@ fn inside_first_orders_each_pens_paths() {
|
||||
}
|
||||
|
||||
/// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in
|
||||
/// paint order, with a warning.
|
||||
/// paint order, with a note on the option.
|
||||
#[test]
|
||||
fn inside_first_past_its_budget_draws_in_paint_order() {
|
||||
let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect());
|
||||
let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) });
|
||||
assert_eq!(out.warnings, [UNNESTED]);
|
||||
assert_eq!(out.setting_notes, [UNNESTED]);
|
||||
let l = lefts(&out);
|
||||
assert_eq!(l.len(), 100);
|
||||
assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}");
|
||||
// With `sort` too, the budget is spent before sorting starts: paint order, with both warnings.
|
||||
// With `sort` too, the budget is spent before sorting starts: paint order, with both notes.
|
||||
let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) });
|
||||
assert_eq!(out.warnings, [UNNESTED, UNSORTED]);
|
||||
assert_eq!(out.setting_notes, [UNNESTED, UNSORTED]);
|
||||
assert_eq!(lefts(&out), l);
|
||||
}
|
||||
|
||||
@@ -969,7 +970,7 @@ fn inside_first_past_its_sorting_budget_keeps_the_rings_last() {
|
||||
v
|
||||
});
|
||||
let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) });
|
||||
assert_eq!(out.warnings, [UNSORTED_INSIDE]);
|
||||
assert_eq!(out.setting_notes, [UNSORTED_INSIDE]);
|
||||
let l = lefts(&out);
|
||||
assert_eq!(l.len(), 602);
|
||||
assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..]));
|
||||
|
||||
@@ -410,6 +410,9 @@ pub struct DxfOutput {
|
||||
pub images: Vec<DxfImage>,
|
||||
/// What was approximated or left out.
|
||||
pub warnings: Vec<String>,
|
||||
/// What was left out because an option turns it off (Include Placed Images or Include Fill
|
||||
/// Hatches off).
|
||||
pub setting_notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// The smallest and largest `scale` (drawing units per unit).
|
||||
|
||||
+15
-3
@@ -520,6 +520,8 @@ pub(crate) struct Scene<'a> {
|
||||
/// Image keys → index into `images` (`None`: left out).
|
||||
by_key: HashMap<String, Option<usize>>,
|
||||
warnings: Vec<String>,
|
||||
/// What was left out because an option asked for it ([`DxfOutput::setting_notes`]).
|
||||
setting_notes: Vec<String>,
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
extents: Option<[f64; 4]>,
|
||||
/// The opacity of the containers around the current object.
|
||||
@@ -554,6 +556,7 @@ impl<'a> Scene<'a> {
|
||||
files: vec![],
|
||||
by_key: HashMap::new(),
|
||||
warnings: vec![],
|
||||
setting_notes: vec![],
|
||||
brushes: None,
|
||||
extents: None,
|
||||
opacity: 1.0,
|
||||
@@ -588,15 +591,24 @@ impl<'a> Scene<'a> {
|
||||
metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters),
|
||||
next: self.next,
|
||||
};
|
||||
DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings }
|
||||
DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings, setting_notes: self.setting_notes }
|
||||
}
|
||||
|
||||
/// Note `w`, once, about something of the document that the drawing leaves out or that reaches
|
||||
/// it changed. A note about an option goes to [`Self::note_setting`].
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Note `w`, once, about something left out because an option turns it off.
|
||||
fn note_setting(&mut self, w: &str) {
|
||||
if !self.setting_notes.iter().any(|x| x == w) {
|
||||
self.setting_notes.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self) -> Handle {
|
||||
let h = Handle(self.next);
|
||||
self.next = self.next.saturating_add(1);
|
||||
@@ -930,7 +942,7 @@ impl<'a> Scene<'a> {
|
||||
// The outlines close as the fill does, so an open path's fill keeps its closing edge.
|
||||
self.lines(&closed_fill(outline), style, 0.0);
|
||||
if !self.o.include_hatches {
|
||||
self.warn(HATCHES_OFF);
|
||||
self.note_setting(HATCHES_OFF);
|
||||
return;
|
||||
}
|
||||
let h = self.handle();
|
||||
@@ -1321,7 +1333,7 @@ impl<'a> Scene<'a> {
|
||||
return;
|
||||
}
|
||||
if !self.o.include_images {
|
||||
self.warn(IMAGES_OFF);
|
||||
self.note_setting(IMAGES_OFF);
|
||||
return;
|
||||
}
|
||||
let Some(i) = self.image_def(im) else { return };
|
||||
|
||||
+13
-8
@@ -881,12 +881,13 @@ fn fills_without_hatches_keep_their_outlines() {
|
||||
let d = shapes_doc();
|
||||
let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle");
|
||||
assert_eq!(out.warnings, [HATCHES_OFF]);
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
assert_eq!(out.setting_notes, [HATCHES_OFF]);
|
||||
let e = entities(&out);
|
||||
assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight");
|
||||
// Hatches stay by default, with no note.
|
||||
let on = export(&d, &opts(&d)).unwrap();
|
||||
assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF));
|
||||
assert!(kinds(&on).iter().any(|k| k == "HATCH") && on.setting_notes.is_empty());
|
||||
// The outlines are the ones written beside hatches, also for overlapping subpaths, which are
|
||||
// merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way.
|
||||
let compound = doc_with(|d| {
|
||||
@@ -916,13 +917,14 @@ fn fills_without_hatches_keep_their_outlines() {
|
||||
assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered));
|
||||
// Nothing filled: no note.
|
||||
let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||||
assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty());
|
||||
let none = export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap();
|
||||
assert!(none.warnings.is_empty() && none.setting_notes.is_empty());
|
||||
// R12 has no hatches anyway: the same outlines, and its own note alone.
|
||||
let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap();
|
||||
let (off, on) = (r12(false), r12(true));
|
||||
assert_eq!(off.bytes, on.bytes);
|
||||
assert_eq!(off.warnings, on.warnings);
|
||||
assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF));
|
||||
assert!(off.setting_notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -957,15 +959,18 @@ fn images_can_be_left_out_with_their_files() {
|
||||
let p = pairs(&out.bytes);
|
||||
assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES")));
|
||||
assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE")));
|
||||
assert_eq!(out.warnings, [IMAGES_OFF]);
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
assert_eq!(out.setting_notes, [IMAGES_OFF]);
|
||||
// An image that can't be decoded is not read when images are left out.
|
||||
let mut broken = d.clone();
|
||||
broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None });
|
||||
assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]);
|
||||
let left_out = export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap();
|
||||
assert!(left_out.warnings.is_empty(), "{:?}", left_out.warnings);
|
||||
assert_eq!(left_out.setting_notes, [IMAGES_OFF]);
|
||||
assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded")));
|
||||
// R12 has no images anyway: its own note alone.
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap();
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF));
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("no images")) && r12.setting_notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -983,7 +988,7 @@ fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_b
|
||||
let cnc = run(DxfPreset::Cnc.options());
|
||||
assert_eq!(kinds(&cnc), ["LWPOLYLINE"]);
|
||||
assert!(cnc.images.is_empty());
|
||||
assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings);
|
||||
assert!(cnc.setting_notes.iter().any(|w| w == IMAGES_OFF) && cnc.setting_notes.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.setting_notes);
|
||||
let custom = DxfPreset::Custom.options();
|
||||
assert!(custom.include_images && custom.include_hatches, "Custom keeps both");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
use kurbo::{Point, Rect, Vec2};
|
||||
|
||||
use super::{Gradient, GradientGeom, GradientKind, GradientSpread, GradientStop};
|
||||
use super::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientSpread, GradientStop};
|
||||
use crate::Color;
|
||||
|
||||
/// Most runs of the stops [`Gradient::padded_over`] writes.
|
||||
pub const MAX_SPREAD_RUNS: usize = 256;
|
||||
@@ -33,8 +34,8 @@ pub struct Padded {
|
||||
pub first: f64,
|
||||
/// How many runs are written, evenly spaced from [`Self::first`] to 1.
|
||||
pub runs: usize,
|
||||
/// The area needed more runs than [`MAX_SPREAD_RUNS`] (or than [`MAX_SPREAD_STOPS`] allows),
|
||||
/// or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written.
|
||||
/// The area needed more runs than [`MAX_SPREAD_RUNS`] (or more than half the stops allowed,
|
||||
/// two to a run), or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written.
|
||||
pub capped: bool,
|
||||
}
|
||||
|
||||
@@ -50,7 +51,9 @@ impl Gradient {
|
||||
self.padded_within(geom, area, MAX_SPREAD_STOPS)
|
||||
}
|
||||
|
||||
/// [`Self::padded_over`] with at most `max_stops` stops (and at least one run).
|
||||
/// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). When the runs
|
||||
/// written need more stops than that, each run leaves out the stops that change its colors
|
||||
/// least ([`fit_run`]), so every run is still written.
|
||||
pub fn padded_within(&self, geom: &GradientGeom, area: Rect, max_stops: usize) -> Option<Padded> {
|
||||
let kind = self.kind;
|
||||
if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() {
|
||||
@@ -68,8 +71,8 @@ impl Gradient {
|
||||
// smallest 0 when the area holds the rings' center, else on its edge.
|
||||
let lo = if centered { inner_level(&period, kind, area).floor() } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() };
|
||||
let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0);
|
||||
let one = whole_run(&self.stops);
|
||||
let most = MAX_SPREAD_RUNS.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1);
|
||||
let budget = max_stops.min(MAX_SPREAD_STOPS);
|
||||
let most = MAX_SPREAD_RUNS.min(budget / 2).max(1);
|
||||
let capped = hi - lo > most as f64;
|
||||
// Capped, the runs kept are the area's innermost ones (radial and diamond), or (linear)
|
||||
// those around the vector's start where the area reaches it, else the area's runs nearest
|
||||
@@ -79,6 +82,7 @@ impl Gradient {
|
||||
_ => lo,
|
||||
};
|
||||
let n = (hi - lo).min(most as f64).max(1.0);
|
||||
let one = fit_run(whole_run(&self.stops), budget / n as usize, self.method);
|
||||
// A radial or diamond writes its runs among the levels from 0, which its stops' offsets
|
||||
// (single-precision fractions of the outermost level) tell apart only so far out, the
|
||||
// nearer the closer its stops lie: past that, the runs end there and the gradient pads.
|
||||
@@ -112,8 +116,9 @@ impl Gradient {
|
||||
self.unrolled_within(MAX_SPREAD_STOPS)
|
||||
}
|
||||
|
||||
/// [`Self::unrolled`] with at most `max_stops` stops (and at least one run): past the runs
|
||||
/// that fit, the last one pads.
|
||||
/// [`Self::unrolled`] with at most `max_stops` stops: every run, each run without the stops
|
||||
/// that change its colors least ([`fit_run`]) when the runs need more stops than that. Only with
|
||||
/// fewer than two stops for each run do the runs past those that fit pad with the last one.
|
||||
pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> {
|
||||
use std::borrow::Cow;
|
||||
if !self.is_spread() {
|
||||
@@ -123,13 +128,29 @@ impl Gradient {
|
||||
if self.stops.is_empty() {
|
||||
return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() });
|
||||
}
|
||||
let one = whole_run(&self.stops);
|
||||
let n = self.repeat_count() as usize;
|
||||
let kept = n.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1);
|
||||
let budget = max_stops.min(MAX_SPREAD_STOPS);
|
||||
let kept = n.min(budget / 2).max(1);
|
||||
let one = fit_run(whole_run(&self.stops), budget / kept, self.method);
|
||||
let stops = self.runs(&one, false, 0.0, kept, n as f64);
|
||||
Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() })
|
||||
}
|
||||
|
||||
/// The gradient as [`Self::unrolled`] gives it with at most `max_stops` stops (at least two):
|
||||
/// a gradient that repeats or reflects as [`Self::unrolled_within`] gives it, and one that
|
||||
/// pads with more stops than that without the stops that change its colors least
|
||||
/// ([`fit_run`]). Others as they are.
|
||||
pub fn fitted(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> {
|
||||
use std::borrow::Cow;
|
||||
if self.is_spread() {
|
||||
return self.unrolled_within(max_stops);
|
||||
}
|
||||
if self.stops.len() <= max_stops.max(2) {
|
||||
return Cow::Borrowed(self);
|
||||
}
|
||||
Cow::Owned(Gradient { stops: fit_run(whole_run(&self.stops), max_stops, self.method), ..self.clone() })
|
||||
}
|
||||
|
||||
/// One cycle of a gradient that repeats or reflects, which repeated end to end paints it, as
|
||||
/// a gradient that pads: one run of the stops (Repeat), or a run and its mirror (Reflect),
|
||||
/// between 0 and 1, and how many runs that is (1 or 2). `None` for a gradient that pads and
|
||||
@@ -257,6 +278,91 @@ fn whole_run(stops: &[GradientStop]) -> Vec<GradientStop> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `one` (a run of a gradient's stops, [`whole_run`]) with at most `budget` stops (at least two):
|
||||
/// as it is when it fits, else without the stops that change its colors least, left out one at a
|
||||
/// time. Leaving out a stop costs how far its color lies from the one that mixing the stops still
|
||||
/// on either side of it by `method` gives there, and how far its CMYK inks, opacity, tint or
|
||||
/// process plates lie from the line between theirs (a whole unit when its swatch link or color
|
||||
/// model differs from either of theirs, and a whole unit more when its midpoint or the one before
|
||||
/// it is off center), so the ends, sharp steps, ink changes and shaped spans stay. A stop whose
|
||||
/// next stop was left out gets its midpoint centered. The work is the stops times their logarithm.
|
||||
fn fit_run(one: Vec<GradientStop>, budget: usize, method: GradientMethod) -> Vec<GradientStop> {
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BinaryHeap;
|
||||
let budget = budget.max(2);
|
||||
let n = one.len();
|
||||
if n <= budget {
|
||||
return one;
|
||||
}
|
||||
let mix = |x: &Color, y: &Color, v: f32| match method {
|
||||
GradientMethod::Classic => x.lerp_keep_model(y, v),
|
||||
m => m.mix(x, y, v),
|
||||
};
|
||||
let cost = |a: &GradientStop, s: &GradientStop, b: &GradientStop| -> u32 {
|
||||
let span = b.offset - a.offset;
|
||||
let u = if span > 0.0 { ((s.offset - a.offset) / span).clamp(0.0, 1.0) } else { 0.0 };
|
||||
// Left out, the span from `a` to `b` has a centered midpoint, so it mixes them by `u`.
|
||||
let (want, cs) = (mix(&a.color, &b.color, u).to_rgb(), s.color.to_rgb());
|
||||
let color = (0..3).map(|k| (want[k] - cs[k]).abs()).fold(0.0, f32::max);
|
||||
let opacity = (a.opacity + (b.opacity - a.opacity) * u - s.opacity).abs();
|
||||
// Process inks, which colors that look alike can print differently: off their line, or a
|
||||
// change of color model.
|
||||
let process = match (&a.color, &s.color, &b.color) {
|
||||
(Color::Cmyk { c: c0, m: m0, y: y0, k: k0 }, Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c1, m: m1, y: y1, k: k1 }) => {
|
||||
[(c0, c, c1), (m0, m, m1), (y0, y, y1), (k0, k, k1)].iter().map(|(p, q, r)| (*p + (*r - *p) * u - *q).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
_ if a.color.model() == s.color.model() && s.color.model() == b.color.model() => 0.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
let color = color.max(process);
|
||||
// Separations: a change of swatch link, or of tint or process plates off their line.
|
||||
let ink = if s.swatch != a.swatch || s.swatch != b.swatch {
|
||||
1.0
|
||||
} else {
|
||||
let tint = (a.tint + (b.tint - a.tint) * u - s.tint).abs();
|
||||
let plates = match (a.plates, s.plates, b.plates) {
|
||||
(None, None, None) => 0.0,
|
||||
(Some(pa), Some(ps), Some(pb)) => (0..4).map(|k| (pa[k] + (pb[k] - pa[k]) * u - ps[k]).abs()).fold(0.0, f32::max),
|
||||
_ => 1.0,
|
||||
};
|
||||
tint.max(plates)
|
||||
};
|
||||
// Leaving it out merges two spans, and an off-center midpoint shapes one of them.
|
||||
let shaped = if [a.midpoint, s.midpoint].iter().any(|m| (m - 0.5).abs() > 1e-3) { 1.0 } else { 0.0 };
|
||||
// Non-negative, so the bits order as the numbers do (NaN counts as 0).
|
||||
(color.max(opacity).max(ink) + shaped).max(0.0).to_bits()
|
||||
};
|
||||
// The stops still kept on either side of each stop, and each stop's latest cost entry.
|
||||
let mut prev: Vec<usize> = (0..n).map(|i| i.saturating_sub(1)).collect();
|
||||
let mut next: Vec<usize> = (1..=n).collect();
|
||||
let mut gone = vec![false; n];
|
||||
let mut version = vec![0u32; n];
|
||||
let mut heap: BinaryHeap<Reverse<(u32, usize, u32)>> = (1..n - 1).map(|i| Reverse((cost(&one[i - 1], &one[i], &one[i + 1]), i, 0))).collect();
|
||||
let mut left = n;
|
||||
while left > budget {
|
||||
let Some(Reverse((_, i, v))) = heap.pop() else { break };
|
||||
if gone[i] || v != version[i] {
|
||||
continue;
|
||||
}
|
||||
gone[i] = true;
|
||||
left -= 1;
|
||||
let (a, b) = (prev[i], next[i]);
|
||||
next[a] = b;
|
||||
prev[b] = a;
|
||||
for j in [a, b] {
|
||||
if j != 0 && j != n - 1 {
|
||||
version[j] += 1;
|
||||
heap.push(Reverse((cost(&one[prev[j]], &one[j], &one[next[j]]), j, version[j])));
|
||||
}
|
||||
}
|
||||
}
|
||||
one.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !gone[*i])
|
||||
.map(|(i, s)| if next[i] == i + 1 { s } else { GradientStop { midpoint: 0.5, ..s } })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A run mirrored: the stops in reverse, each midpoint measured from the other side.
|
||||
fn mirror(run: &[GradientStop]) -> Vec<GradientStop> {
|
||||
let mut g = Gradient::new(GradientKind::Linear, run.to_vec());
|
||||
|
||||
@@ -148,11 +148,12 @@ fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A run of the stops far too short for the area, or a gradient of many stops, is written with at
|
||||
/// most so many runs and stops, and pads past them; a conic, a padding gradient and one without
|
||||
/// stops have nothing to pad.
|
||||
/// A run of the stops far too short for the area is written with at most so many runs, and pads
|
||||
/// past them; a gradient of many stops is written with every run the area needs, each run
|
||||
/// without the stops that change its colors least, within so many stops; a conic, a padding
|
||||
/// gradient and one without stops have nothing to pad.
|
||||
#[test]
|
||||
fn far_repeats_and_many_stops_are_capped() {
|
||||
fn far_repeats_are_capped_and_many_stops_are_sampled() {
|
||||
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None };
|
||||
let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) };
|
||||
let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap();
|
||||
@@ -162,7 +163,13 @@ fn far_repeats_and_many_stops_are_capped() {
|
||||
let g = Gradient { spread: GradientSpread::Reflect, repeats: 50, ..Gradient::new(GradientKind::Linear, many) };
|
||||
let wide = GradientGeom { end: Point::new(100.0, 0.0), ..geom };
|
||||
let p = g.padded_over(&wide, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap();
|
||||
assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{}", p.gradient.stops.len());
|
||||
assert!(!p.capped && p.runs == 50 && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {}", p.runs, p.gradient.stops.len());
|
||||
for x in [0.7, 33.3, 66.1, 99.5] {
|
||||
let q = Point::new(x, 0.5);
|
||||
let want = g.sample(g.spread_param(wide.param_at(GradientKind::Linear, q)) as f32).0.to_rgb();
|
||||
let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0.to_rgb();
|
||||
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}");
|
||||
}
|
||||
assert!(g.unrolled().stops.len() <= MAX_SPREAD_STOPS);
|
||||
assert!(p.gradient.padded_over(&p.geom, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none(), "it pads");
|
||||
assert!(spread(GradientKind::Conic, GradientSpread::Repeat, 3).padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none());
|
||||
@@ -325,6 +332,111 @@ fn unrolled_and_cycle_stops() {
|
||||
assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
/// A gradient of more stops than [`MAX_SPREAD_STOPS`] allows for all its runs keeps every run
|
||||
/// when unrolled, each run without the stops that change its colors least: its last run ends on
|
||||
/// its last color instead of the rest padding with it.
|
||||
#[test]
|
||||
fn unrolled_many_stops_keep_every_run() {
|
||||
let stops: Vec<GradientStop> = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(i as f32 / 199.0))).collect();
|
||||
let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, stops) };
|
||||
let u = g.unrolled();
|
||||
assert!(u.stops.len() <= MAX_SPREAD_STOPS, "{}", u.stops.len());
|
||||
for t in [0.005, 0.5051, 0.9149, 0.9951] {
|
||||
let want = g.sample(g.spread_param(t) as f32).0.to_rgb();
|
||||
let got = u.sample(t as f32).0.to_rgb();
|
||||
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{t}: {want:?} vs {got:?}");
|
||||
}
|
||||
// A padding gradient fitted to fewer stops keeps its ends.
|
||||
let pad = Gradient::new(GradientKind::Linear, g.stops.clone());
|
||||
let fitted = pad.fitted(16);
|
||||
assert_eq!(fitted.stops.len(), 16);
|
||||
for (stop, want) in [(&fitted.stops[0], Color::gray(0.0)), (&fitted.stops[15], Color::gray(1.0))] {
|
||||
assert!(stop.color.to_rgb().iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "{:?}", stop.color);
|
||||
}
|
||||
assert!(matches!(pad.fitted(500), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
/// One run of more stops than the limit (255 inside it, and a copy of each end color at 0 and 1)
|
||||
/// is written within the limit, every run the area needs included.
|
||||
#[test]
|
||||
fn a_run_over_the_stop_limit_is_written_within_it() {
|
||||
let stops: Vec<GradientStop> = (1..=255).map(|i| GradientStop::new(i as f32 / 256.0, Color::gray(i as f32 / 256.0))).collect();
|
||||
let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, stops) };
|
||||
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None };
|
||||
let p = g.padded_within(&geom, Rect::new(0.0, 0.0, 20.0, 1.0), 256).unwrap();
|
||||
assert!(!p.capped && p.runs == 2 && p.gradient.stops.len() <= 256, "{} {} {}", p.capped, p.runs, p.gradient.stops.len());
|
||||
assert!(g.unrolled_within(256).stops.len() <= 256);
|
||||
}
|
||||
|
||||
/// Fitting a run to fewer stops keeps a narrow band between two sharp steps, a stop linked to a
|
||||
/// swatch, and each kept stop's link and tint, where sampling at evenly spaced places would lose
|
||||
/// them.
|
||||
#[test]
|
||||
fn fitting_keeps_sharp_steps_and_swatch_links() {
|
||||
let mut stops: Vec<GradientStop> = (0..300).map(|i| GradientStop::new(i as f32 / 299.0, Color::gray(i as f32 / 299.0))).collect();
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
// A band of red from 0.5003 to 0.5013, with a sharp step at each side.
|
||||
for (offset, color) in [(0.5003, None), (0.5003, Some(red)), (0.5013, Some(red)), (0.5013, None)] {
|
||||
let gray = Color::gray(offset);
|
||||
stops.push(GradientStop::new(offset, color.unwrap_or(gray)));
|
||||
}
|
||||
let spot = GradientStop { swatch: Some("Ink".into()), tint: 0.4, ..GradientStop::new(0.25, Color::gray(0.25)) };
|
||||
stops.push(spot.clone());
|
||||
// Three stops of one ink that look alike but print at different tints.
|
||||
for (offset, tint) in [(0.8, 0.2), (0.81, 0.9), (0.82, 0.2)] {
|
||||
stops.push(GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(offset)) });
|
||||
}
|
||||
// A span shaped by an off-center midpoint.
|
||||
if let Some(s) = stops.iter_mut().find(|s| (s.offset - 0.6).abs() < 2e-3) {
|
||||
s.midpoint = 0.13;
|
||||
}
|
||||
stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
let g = Gradient::new(GradientKind::Linear, stops);
|
||||
let fitted = g.fitted(32);
|
||||
assert!(fitted.stops.len() <= 32, "{}", fitted.stops.len());
|
||||
let red_at = |t: f32| fitted.sample(t).0.to_rgb();
|
||||
assert_eq!(red_at(0.5008), [1.0, 0.0, 0.0], "the band stays");
|
||||
assert!(red_at(0.499)[1] > 0.4 && red_at(0.503)[1] > 0.4, "the band stays narrow");
|
||||
assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.4 && s.offset == 0.25), "{:?}", fitted.stops);
|
||||
assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink2") && s.tint == 0.9), "the tint change stays");
|
||||
assert!(fitted.stops.iter().any(|s| s.midpoint == 0.13), "the shaped span stays");
|
||||
// Of a stop that changes only its ink's tint and one that the colors' line nearly gives, the
|
||||
// first stays. (The stop before an ink starts stays as well: leaving it out would blend the
|
||||
// ink in sooner.)
|
||||
let ink = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(0.5)) };
|
||||
let few = vec![
|
||||
GradientStop::new(0.0, Color::gray(0.0)),
|
||||
GradientStop::new(0.2, Color::gray(0.17)),
|
||||
GradientStop::new(0.3, Color::gray(0.3)),
|
||||
ink(0.4, 0.2),
|
||||
ink(0.5, 0.9),
|
||||
ink(0.6, 0.2),
|
||||
GradientStop::new(1.0, Color::gray(1.0)),
|
||||
];
|
||||
let fitted = Gradient::new(GradientKind::Linear, few).fitted(6).into_owned();
|
||||
let offsets: Vec<f32> = fitted.stops.iter().map(|s| s.offset).collect();
|
||||
assert_eq!(offsets, [0.0, 0.3, 0.4, 0.5, 0.6, 1.0]);
|
||||
// A Perceptual gradient leaves out the stop its own mixing gives, not the one the RGB line
|
||||
// gives.
|
||||
let (blue, yellow, gray) = (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0), Color::rgb(0.5, 0.5, 0.5));
|
||||
let on_curve = GradientMethod::Perceptual.mix(&gray, &yellow, 0.5);
|
||||
let stops = vec![GradientStop::new(0.0, blue), GradientStop::new(0.5, gray), GradientStop::new(0.75, on_curve), GradientStop::new(1.0, yellow)];
|
||||
let perceptual = Gradient { method: GradientMethod::Perceptual, ..Gradient::new(GradientKind::Linear, stops) };
|
||||
let fitted = perceptual.fitted(3).into_owned();
|
||||
let offsets: Vec<f32> = fitted.stops.iter().map(|s| s.offset).collect();
|
||||
assert_eq!(offsets, [0.0, 0.5, 1.0]);
|
||||
// A black of other inks between blacks that look the same prints differently: it stays.
|
||||
let k = |offset: f32| GradientStop::new(offset, Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0 });
|
||||
let rich = GradientStop::new(0.5, Color::Cmyk { c: 0.6, m: 0.4, y: 0.4, k: 1.0 });
|
||||
let blacks = vec![k(0.0), k(0.4), rich, k(0.6), k(1.0)];
|
||||
let fitted = Gradient::new(GradientKind::Linear, blacks).fitted(3).into_owned();
|
||||
let offsets: Vec<f32> = fitted.stops.iter().map(|s| s.offset).collect();
|
||||
assert_eq!(offsets, [0.0, 0.5, 1.0]);
|
||||
let spread = Gradient { spread: GradientSpread::Repeat, repeats: 4, ..g.clone() };
|
||||
let u = spread.unrolled_within(128);
|
||||
assert_eq!(u.stops.iter().filter(|s| s.swatch.as_deref() == Some("Ink")).count(), 4, "one linked stop a run");
|
||||
}
|
||||
|
||||
/// Pixels (what the canvas draws a diamond, dithered or Perceptual gradient as, and what SVG and
|
||||
/// PDF write as images) repeat with the stops, for each kind.
|
||||
#[test]
|
||||
|
||||
+233
-67
@@ -1,6 +1,7 @@
|
||||
//! Where a path's strokes paint, from the path's geometry without building their outlines: the
|
||||
//! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes
|
||||
//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points.
|
||||
//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points,
|
||||
//! and a bevel ends at the corners of the two segments it joins.
|
||||
|
||||
use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit};
|
||||
use vectorcraft_geom::hit::fill_contains;
|
||||
@@ -39,10 +40,20 @@ fn joints(segs: &[PathSeg], closed: bool) -> impl Iterator<Item = (Point, Vec2,
|
||||
/// path: its tip and the corner's outer offset points, or `None` when the path doesn't turn there
|
||||
/// or the miter limit bevels it.
|
||||
fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[Point; 3]> {
|
||||
let cos = t_in.dot(t_out);
|
||||
// cos(half the turn) is sin(half the angle between the segments): the miter is side / it.
|
||||
let half = ((1.0 + cos) / 2.0).max(0.0).sqrt();
|
||||
if cos >= CORNER_COS || half * limit < 1.0 {
|
||||
let half = ((1.0 + t_in.dot(t_out)) / 2.0).max(0.0).sqrt();
|
||||
if half * limit < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let ([a, b], out) = bevel(at, t_in, t_out, side)?;
|
||||
Some([a, at + out * (side / half), b])
|
||||
}
|
||||
|
||||
/// The bevel at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its path:
|
||||
/// the outer corners of the two segments' strokes and the unit direction out of the turn, or
|
||||
/// `None` when the path doesn't turn there.
|
||||
fn bevel(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<([Point; 2], Vec2)> {
|
||||
if t_in.dot(t_out) >= CORNER_COS {
|
||||
return None;
|
||||
}
|
||||
let out = unit(t_in - t_out);
|
||||
@@ -51,7 +62,7 @@ fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[P
|
||||
let n = Vec2::new(-t.y, t.x);
|
||||
if n.dot(out) >= 0.0 { n } else { -n }
|
||||
};
|
||||
Some([at + outer(t_in) * side, at + out * (side / half), at + outer(t_out) * side])
|
||||
Some(([at + outer(t_in) * side, at + outer(t_out) * side], out))
|
||||
}
|
||||
|
||||
/// The heads' ends of `bp`: the start of its first subpath and the end of its last, when open (as
|
||||
@@ -187,26 +198,44 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool {
|
||||
}
|
||||
|
||||
impl StrokeLayer {
|
||||
/// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads,
|
||||
/// an alignment that depends on whether the path is closed, or butt caps, which stop at the
|
||||
/// ends of an open path? Else it reaches evenly round it.
|
||||
/// Does bounding this stroke need its path's shape: miter spikes, bevels, projecting caps,
|
||||
/// arrowheads, an alignment that depends on whether the path is closed, or butt caps, which
|
||||
/// stop at the ends of an open path? Else it reaches evenly round it.
|
||||
fn needs_shape(&self) -> bool {
|
||||
self.join == LineJoin::Miter
|
||||
self.join != LineJoin::Round
|
||||
|| self.cap != LineCap::Round
|
||||
|| self.start_arrow.is_some()
|
||||
|| self.end_arrow.is_some()
|
||||
|| self.align != StrokeAlign::Center
|
||||
}
|
||||
|
||||
/// The box this stroke paints along `segs`, the segments with length of an open subpath, when
|
||||
/// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each
|
||||
/// joint between segments paints ([`round_join_box`]), a shallow one included.
|
||||
/// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips
|
||||
/// are measured apart. A segment in the middle is measured only when its own box grown by
|
||||
/// `side`, which holds everything it paints, is not inside the box so far.
|
||||
fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option<Rect> {
|
||||
/// Does this stroke stop at the corners of its segments along a subpath that is `closed` or
|
||||
/// open, where its caps are butt caps on an open subpath or its joins are bevels or miters?
|
||||
/// Dashes with round or projecting caps can end at a corner, so with them only butt caps stop.
|
||||
fn stops_at_corners(&self, closed: bool) -> bool {
|
||||
let capped_dashes = self.cap != LineCap::Butt && self.dash.as_ref().is_some_and(Dash::is_dashed);
|
||||
(self.cap == LineCap::Butt && !closed) || (self.join != LineJoin::Round && !capped_dashes)
|
||||
}
|
||||
|
||||
/// Does this stroke reach past a subpath that is `closed` or open by a margin that follows the
|
||||
/// direction of its segments at their corners: where it stops at them
|
||||
/// ([`Self::stops_at_corners`]) or paints miter spikes, which dashes with round or projecting
|
||||
/// caps also paint?
|
||||
fn reaches_by_corners(&self, closed: bool) -> bool {
|
||||
self.stops_at_corners(closed) || self.join == LineJoin::Miter
|
||||
}
|
||||
|
||||
/// The box this stroke's body paints along `segs`, the segments with length of a subpath, short
|
||||
/// of its caps, joined with `within`: each segment's [`swept_box`] and, with round joins on an
|
||||
/// open subpath, the arc each joint between segments paints ([`round_join_box`]), a shallow one
|
||||
/// included. A bevel stays inside the boxes of the segments it joins. `None` when `segs` is
|
||||
/// empty. Butt dashes stop inside the same box. The caps other than butt ones, the heads and
|
||||
/// miter tips are measured apart. A segment in the middle is measured only when its own box
|
||||
/// grown by `side`, which holds everything it paints, is not inside the box so far, which
|
||||
/// starts as `within` and the first and last segments' boxes.
|
||||
fn body_along(&self, segs: &[PathSeg], side: f64, within: Rect) -> Option<Rect> {
|
||||
let (first, last) = (segs.first()?, segs.last()?);
|
||||
let ends = swept_box(first, side, 3).union(swept_box(last, side, 3));
|
||||
let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)).union(within);
|
||||
let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| {
|
||||
if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) }
|
||||
});
|
||||
@@ -217,24 +246,28 @@ impl StrokeLayer {
|
||||
}
|
||||
|
||||
/// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt
|
||||
/// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its
|
||||
/// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of
|
||||
/// projecting caps and the reach of the arrowheads round their end points.
|
||||
/// caps, and every subpath when the joins are bevels or miters, by [`Self::body_along`] with its
|
||||
/// caps, every other subpath by the box of its segments grown by its reach, with `tips` and,
|
||||
/// when `measure_tips`, the miter spikes the limit keeps, the corners of projecting caps and the
|
||||
/// reach of the arrowheads round their end points.
|
||||
fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>, measure_tips: bool) -> Rect {
|
||||
let els = bp.elements();
|
||||
let subs = subpaths(bp);
|
||||
let mut out = tips.map_or(bounds, |t| bounds.union(t));
|
||||
let mut side_max: f64 = 0.0;
|
||||
// Every subpath reaches evenly unless one is open and butt-capped; only then is each
|
||||
// subpath's own box needed.
|
||||
let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1);
|
||||
let mut even = even_only.then_some(bounds);
|
||||
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
|
||||
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
|
||||
// A butt cap stops at an open subpath's end, and a bevel, or a miter the limit bevels, at
|
||||
// the two segments' corners, so such a subpath is measured along its segments. Every
|
||||
// subpath reaches evenly unless one is measured that way; only then is each subpath's own
|
||||
// box needed.
|
||||
let even_only = !subs.iter().any(|s| self.stops_at_corners(s.1));
|
||||
let mut even = even_only.then_some(bounds);
|
||||
for (r, closed) in subs {
|
||||
let side = self.side_reach(closed);
|
||||
let butt = self.cap == LineCap::Butt && !closed;
|
||||
if !butt {
|
||||
let along = self.stops_at_corners(closed);
|
||||
if !along {
|
||||
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
|
||||
}
|
||||
if side <= 0.0 {
|
||||
@@ -242,10 +275,10 @@ impl StrokeLayer {
|
||||
}
|
||||
let square = self.cap == LineCap::Square && !closed;
|
||||
let miters = measure_tips && self.join == LineJoin::Miter;
|
||||
if !(butt || square || miters || !even_only) {
|
||||
if !(along || square || miters || !even_only) {
|
||||
continue;
|
||||
}
|
||||
let segs = drawn(&els[r]);
|
||||
let segs = drawn(&els[r.clone()]);
|
||||
if miters {
|
||||
for (at, t_in, t_out) in joints(&segs, closed) {
|
||||
if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) {
|
||||
@@ -253,13 +286,24 @@ impl StrokeLayer {
|
||||
}
|
||||
}
|
||||
}
|
||||
if butt {
|
||||
if let Some(body) = self.butt_body(&segs, side) {
|
||||
out = out.union(body);
|
||||
if along {
|
||||
// The path's bounds are in the box already, so only the segments that can reach
|
||||
// past them are measured.
|
||||
match self.body_along(&segs, side, out) {
|
||||
Some(body) => out = out.union(body),
|
||||
// A subpath with no length paints at most its caps' dot.
|
||||
None if !butt => {
|
||||
let dot = segments(&els[r]).iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b));
|
||||
out = dot.map_or(out, |d| out.union(d.inflate(side, side)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !even_only {
|
||||
if self.cap == LineCap::Round && !closed {
|
||||
for (at, _) in ends(&segs).into_iter().flatten() {
|
||||
out = out.union(Rect::from_center_size(at, (2.0 * side, 2.0 * side)));
|
||||
}
|
||||
}
|
||||
} else if !even_only {
|
||||
even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b)));
|
||||
}
|
||||
if square {
|
||||
@@ -279,7 +323,8 @@ impl StrokeLayer {
|
||||
/// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown
|
||||
/// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of
|
||||
/// projecting caps and the reach of the arrowheads round their end points. An open subpath
|
||||
/// with butt caps does not reach past its ends ([`Self::butt_body`]).
|
||||
/// with butt caps does not reach past its ends, and a bevel does not reach round its corner
|
||||
/// ([`Self::body_along`]).
|
||||
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
|
||||
self.reach_along(bp, bounds, None, true)
|
||||
}
|
||||
@@ -329,6 +374,9 @@ impl StrokeLayer {
|
||||
let segs = drawn(&els[r]);
|
||||
let (half, region) = self.body(closed);
|
||||
let full = half * self.profile_max() * dash_caps.unwrap_or(1.0);
|
||||
// Past a butt cap or a bevel the stroke stops at the segments' corners. A width profile
|
||||
// keeps the round reach at the ends.
|
||||
let squared = self.profile.is_none() && self.stops_at_corners(closed);
|
||||
// Every segment near enough to reach `p`: with a width profile the wider side of a
|
||||
// farther segment can paint past the nearest one, so the nearest alone is not enough.
|
||||
let near = segs.iter().enumerate().filter_map(|(i, s)| {
|
||||
@@ -365,7 +413,8 @@ impl StrokeLayer {
|
||||
}
|
||||
})
|
||||
};
|
||||
if d <= half * factor.max(0.0) + tol && on_side(region, d) {
|
||||
if d <= half * factor.max(0.0) + tol && on_side(region, d) && (!squared || self.paints_at_end(&segs, closed, (i, nr.t), p, half, tol))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -393,6 +442,35 @@ impl StrokeLayer {
|
||||
false
|
||||
}
|
||||
|
||||
/// Does this stroke, where it stops at the corners of its segments ([`Self::stops_at_corners`]),
|
||||
/// paint `p`, which is within `side` (and `tol`) of segment `i` of the subpath `segs`
|
||||
/// (`closed`: a closed one) and nearest to it at `t`? It does when `p` is not past the end of
|
||||
/// the segment that `t` is at, by more than `tol`, and past it when a round cap or a round join
|
||||
/// paints there, when the path does not turn at that end, or when `p` is within `tol` of the
|
||||
/// bevel's triangle. Projecting caps and
|
||||
/// miter spikes are checked apart. A curve that bends more tightly than `side` ([`gentle`])
|
||||
/// counts as painting round its ends.
|
||||
fn paints_at_end(&self, segs: &[PathSeg], closed: bool, (i, t): (usize, f64), p: Point, side: f64, tol: f64) -> bool {
|
||||
let (n, at_start) = (segs.len(), t <= 1e-9);
|
||||
let Some(seg) = segs.get(i) else { return true };
|
||||
if (!at_start && t < 1.0 - 1e-9) || !(matches!(seg, PathSeg::Line(_)) || gentle(seg, side)) {
|
||||
return true;
|
||||
}
|
||||
let (at, out) = if at_start { (seg.start(), -tangent(seg, 0.0)) } else { (seg.end(), tangent(seg, 1.0)) };
|
||||
if (p - at).dot(out) <= tol {
|
||||
return true;
|
||||
}
|
||||
// The segment on the other side of this end, `None` at the end of an open subpath.
|
||||
let other = if at_start { (i > 0 || closed).then(|| (i + n - 1) % n) } else { (i + 1 < n || closed).then(|| (i + 1) % n) };
|
||||
let Some(other) = other.and_then(|o| segs.get(o)) else { return self.cap == LineCap::Round };
|
||||
if self.join == LineJoin::Round {
|
||||
return true;
|
||||
}
|
||||
let (t_in, t_out) = if at_start { (tangent(other, 1.0), tangent(seg, 0.0)) } else { (tangent(seg, 1.0), tangent(other, 0.0)) };
|
||||
// A joint where the path does not turn has no bevel: the stroke runs on through it.
|
||||
bevel(at, t_in, t_out, side).is_none_or(|([a, b], _)| in_convex(&[at, a, b], p, tol))
|
||||
}
|
||||
|
||||
/// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path
|
||||
/// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and
|
||||
/// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). With
|
||||
@@ -428,7 +506,7 @@ impl Appearance {
|
||||
}
|
||||
|
||||
/// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape
|
||||
/// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`.
|
||||
/// (miter spikes, bevels, projecting or butt caps, arrowheads or an alignment) given by `shaped`.
|
||||
pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect {
|
||||
self.painted_strokes().fold(bounds, |acc, s| {
|
||||
if !s.needs_shape() {
|
||||
@@ -439,17 +517,21 @@ impl Appearance {
|
||||
})
|
||||
}
|
||||
|
||||
/// Does this appearance have a visible butt-capped stroke?
|
||||
pub fn has_butt_stroke(&self) -> bool {
|
||||
self.painted_strokes().any(|s| s.cap == LineCap::Butt)
|
||||
/// Does this appearance have a visible stroke that stops at the corners of its segments on an
|
||||
/// open subpath or a closed one, or paints miter spikes: butt caps, or bevel and miter joins
|
||||
/// ([`StrokeLayer::reaches_by_corners`])?
|
||||
pub fn has_cornered_stroke(&self) -> bool {
|
||||
self.painted_strokes().any(|s| s.reaches_by_corners(false))
|
||||
}
|
||||
|
||||
/// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with
|
||||
/// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The
|
||||
/// visual box's margin at the stroke's corners there depends on their direction, which a scale
|
||||
/// out of proportion changes.
|
||||
pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool {
|
||||
if !self.has_butt_stroke() {
|
||||
/// Does a visible stroke of this appearance that stops at the corners of its segments (butt
|
||||
/// caps on an open subpath, bevel and miter joins) or paints miter spikes, dashed ones with
|
||||
/// round or projecting caps included, run along a subpath of `bp` where it does so, with a
|
||||
/// segment that starts or ends at a slant, not square to the axes, once placed by `a`? The
|
||||
/// visual box's margin at those corners, and at miter tips, depends on their direction, which a
|
||||
/// scale out of proportion changes.
|
||||
pub fn has_slanted_stroke_corners(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool {
|
||||
if !self.has_cornered_stroke() {
|
||||
return false;
|
||||
}
|
||||
let slanted = |v: Vec2| {
|
||||
@@ -457,9 +539,10 @@ impl Appearance {
|
||||
(d.x * d.y).abs() > 1e-9
|
||||
};
|
||||
let els = bp.elements();
|
||||
subpaths(bp)
|
||||
.into_iter()
|
||||
.filter(|(_, closed)| !closed)
|
||||
let subs = subpaths(bp);
|
||||
let cornered = |closed: bool| self.painted_strokes().any(|s| s.reaches_by_corners(closed));
|
||||
subs.into_iter()
|
||||
.filter(|(_, closed)| cornered(*closed))
|
||||
.any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0))))
|
||||
}
|
||||
|
||||
@@ -470,28 +553,28 @@ impl Appearance {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object
|
||||
/// How many symbol instances [`crate::Node::has_slanted_stroke_corners`] looks into for one object
|
||||
/// before it answers yes without looking further.
|
||||
pub const SLANT_INSTANCES: usize = 4096;
|
||||
|
||||
impl crate::Node {
|
||||
/// Does a visible butt-capped stroke of this object, of an object in it or of an object in a
|
||||
/// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment
|
||||
/// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it
|
||||
/// answers yes.
|
||||
pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool {
|
||||
self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES))
|
||||
/// Does a visible stroke of this object, of an object in it or of an object in a symbol
|
||||
/// instance's art from `symbols`, placed by `a`, stop at the corners of a segment at a slant
|
||||
/// ([`Appearance::has_slanted_stroke_corners`])? Past [`SLANT_INSTANCES`] instances it answers
|
||||
/// yes.
|
||||
pub fn has_slanted_stroke_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool {
|
||||
self.slanted_corners(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES))
|
||||
}
|
||||
|
||||
/// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into.
|
||||
fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell<usize>) -> bool {
|
||||
/// [`Self::has_slanted_stroke_corners`] with `left` instances still to look into.
|
||||
fn slanted_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell<usize>) -> bool {
|
||||
if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind {
|
||||
let Some(l) = left.get().checked_sub(1) else { return true };
|
||||
left.set(l);
|
||||
return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false);
|
||||
return symbols.measure_art(symbol, |art| art.slanted_corners(symbols, a * *xf, left)).unwrap_or(false);
|
||||
}
|
||||
(self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a)))
|
||||
|| self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left)))
|
||||
(self.appearance.has_cornered_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_stroke_corners(&bp, a)))
|
||||
|| self.children().is_some_and(|c| c.iter().any(|c| c.slanted_corners(symbols, a, left)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,6 +651,79 @@ mod tests {
|
||||
assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bevel_stops_at_the_corners_of_the_segments_it_joins() {
|
||||
// A 40 × 100 pt triangle stroked 20 pt: a miter limit of 2 or a bevel join bevels its
|
||||
// 22.6° tip at (20, 0), so the stroke reaches 10 · 20 / √10400 pt above it, not 10 pt.
|
||||
let mut tri = BezPath::new();
|
||||
tri.move_to((0.0, 100.0));
|
||||
tri.line_to((20.0, 0.0));
|
||||
tri.line_to((40.0, 100.0));
|
||||
tri.close_path();
|
||||
let tb = tri.bounding_box();
|
||||
let (top, side) = (-200.0 / 10400.0_f64.sqrt(), 1000.0 / 10400.0_f64.sqrt());
|
||||
let limited = stroke(20.0, |s| s.miter_limit = 2.0);
|
||||
let bevel = stroke(20.0, |s| {
|
||||
s.join = LineJoin::Bevel;
|
||||
s.cap = LineCap::Round;
|
||||
});
|
||||
let b = limited.bounds_on(&tri, tb);
|
||||
assert!((b.y0 - top).abs() < 1e-9, "{b:?}");
|
||||
// The limit keeps the base corners' miters, whose tips reach about 12.2 pt left and right.
|
||||
assert!(b.x0 < -12.0 && b.x1 > 52.0, "{b:?}");
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 20.0);
|
||||
*ap.stroke_mut().unwrap() = bevel.clone();
|
||||
let b = ap.stroked_bounds(tb, || tri.clone());
|
||||
for (got, want) in [(b.x0, -side), (b.y0, top), (b.x1, 40.0 + side), (b.y1, 110.0)] {
|
||||
assert!((got - want).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
// Open, the same corner is beveled and the round caps reach the half weight round the ends.
|
||||
let chevron = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]);
|
||||
let b = bevel.bounds_on(&chevron, chevron.bounding_box());
|
||||
for (got, want) in [(b.x0, -10.0), (b.y0, top), (b.x1, 50.0), (b.y1, 110.0)] {
|
||||
assert!((got - want).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
// A round join, and the round caps of dashes, which can end at the tip, reach round it.
|
||||
let round = stroke(20.0, |s| s.join = LineJoin::Round);
|
||||
assert!((round.bounds_on(&tri, tb).y0 + 10.0).abs() < 1e-9);
|
||||
let mut dashed = bevel.clone();
|
||||
dashed.dash = Some(Dash { pattern: vec![7.0, 4.0], offset: 0.0, align_corners: false });
|
||||
assert!((dashed.bounds_on(&chevron, chevron.bounding_box()).y0 + 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_past_a_bevel_or_a_butt_end_misses() {
|
||||
// 5 pt above the triangle's tip is within the half weight of it, but past the bevel, which
|
||||
// reaches about 1.96 pt above it. A round join paints there.
|
||||
let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]);
|
||||
tri.close_path();
|
||||
let (above, on) = (Point::new(20.0, -5.0), Point::new(20.0, -1.5));
|
||||
for join in [LineJoin::Bevel, LineJoin::Miter] {
|
||||
let st = stroke(20.0, |s| (s.join, s.miter_limit) = (join, 2.0));
|
||||
assert!(!st.hits(&tri, FillRule::NonZero, above, 0.5), "{join:?}");
|
||||
assert!(st.hits(&tri, FillRule::NonZero, on, 0.0), "{join:?}");
|
||||
}
|
||||
assert!(stroke(20.0, |s| s.join = LineJoin::Round).hits(&tri, FillRule::NonZero, above, 0.5));
|
||||
// 1.5 pt past a butt end of a 4 pt line misses, and a quarter point within it hits; a round
|
||||
// cap paints past it.
|
||||
let line = polyline(&[(0.0, 0.0), (0.0, 50.0)]);
|
||||
let butt = stroke(4.0, |s| s.cap = LineCap::Butt);
|
||||
assert!(!butt.hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25));
|
||||
assert!(butt.hits(&line, FillRule::NonZero, Point::new(1.0, -0.2), 0.25));
|
||||
assert!(stroke(4.0, |s| s.cap = LineCap::Round).hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_joint_too_shallow_to_bevel_paints_past_both_segments_ends() {
|
||||
// The path turns by about 0.57° at (100, -0.5), too little to bevel, so the stroke runs on
|
||||
// through the joint: 3.5 pt above it is past the end of both segments and still painted.
|
||||
let kink = polyline(&[(0.0, 0.0), (100.0, -0.5), (200.0, 0.0)]);
|
||||
let above = Point::new(100.0, -4.0);
|
||||
for join in [LineJoin::Bevel, LineJoin::Miter, LineJoin::Round] {
|
||||
assert!(stroke(10.0, |s| s.join = join).hits(&kink, FillRule::NonZero, above, 0.0), "{join:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An open path through `points`.
|
||||
fn polyline(points: &[(f64, f64)]) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
@@ -693,15 +849,25 @@ mod tests {
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
|
||||
ap.stroke_mut().unwrap().cap = LineCap::Butt;
|
||||
let id = vectorcraft_geom::Affine::IDENTITY;
|
||||
assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id));
|
||||
assert!(!ap.has_slanted_stroke_corners(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id));
|
||||
// Square ends, slanted segments in the middle.
|
||||
let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]);
|
||||
assert!(ap.has_slanted_butt_stroke(&apex, id));
|
||||
// A rotation slants a vertical line; a round cap is never slanted.
|
||||
assert!(ap.has_slanted_stroke_corners(&apex, id));
|
||||
// A rotation slants a vertical line.
|
||||
let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]);
|
||||
assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3)));
|
||||
assert!(ap.has_slanted_stroke_corners(&v, vectorcraft_geom::Affine::rotate(0.3)));
|
||||
// With round caps, the miter join still stops at the corners of the slanted segments, on a
|
||||
// closed path too; round caps and round joins never do.
|
||||
ap.stroke_mut().unwrap().cap = LineCap::Round;
|
||||
assert!(!ap.has_slanted_butt_stroke(&apex, id));
|
||||
assert!(ap.has_slanted_stroke_corners(&apex, id));
|
||||
let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]);
|
||||
tri.close_path();
|
||||
ap.stroke_mut().unwrap().join = LineJoin::Bevel;
|
||||
assert!(ap.has_slanted_stroke_corners(&tri, id));
|
||||
assert!(!ap.has_slanted_stroke_corners(&square(), id));
|
||||
ap.stroke_mut().unwrap().join = LineJoin::Round;
|
||||
assert!(!ap.has_slanted_stroke_corners(&apex, id));
|
||||
assert!(!ap.has_slanted_stroke_corners(&tri, id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -725,11 +891,11 @@ mod tests {
|
||||
}
|
||||
let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
let symbols = d.symbol_boxes();
|
||||
assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY));
|
||||
assert!(top.has_slanted_stroke_corners(&symbols, vectorcraft_geom::Affine::IDENTITY));
|
||||
assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured());
|
||||
// One instance of S1 holds three instances, all looked into.
|
||||
let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY));
|
||||
assert!(!one.has_slanted_stroke_corners(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -89,8 +89,8 @@ pub use revolve::{
|
||||
pub use solids::{
|
||||
CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES,
|
||||
classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in,
|
||||
three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in,
|
||||
validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options,
|
||||
three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid,
|
||||
validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options,
|
||||
};
|
||||
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
|
||||
pub use svg_filter::SVG_FILTER;
|
||||
|
||||
@@ -509,6 +509,60 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The outline of every face [`revolve_art`] draws for `n`'s Revolve or Revolve (Classic) (its symbol
|
||||
/// instances, blends and clipping groups already resolved), in document coordinates: with the
|
||||
/// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of
|
||||
/// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for
|
||||
/// each member, about one shared axis. The swatches and patterns named by a live-effect plug-in
|
||||
/// after a fill's or stroke's Revolve are looked up in `doc`.
|
||||
pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, String> {
|
||||
let faces = |path: &PathData, b: Rect, options: Revolve| -> Result<Vec<PathData>, String> {
|
||||
Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect())
|
||||
};
|
||||
if is_container(n) {
|
||||
let index = n
|
||||
.appearance
|
||||
.effects
|
||||
.iter()
|
||||
.position(|e| e.visible && is_revolve(&e.id))
|
||||
.ok_or("a 3D effect on a group's fill or stroke draws nothing")?;
|
||||
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
|
||||
let mut shapes = members(n, "Revolve")?;
|
||||
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
|
||||
let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?;
|
||||
let options = shared_options(&shapes, fx_options(fx))?;
|
||||
let mut out = vec![];
|
||||
for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) {
|
||||
out.extend(faces(&s.path, b, options)?);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let (path, _) = object_profile(n, "Revolve")?;
|
||||
let ctx = GeomContext::of(n);
|
||||
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) {
|
||||
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
if surface_item(&solid_paints(n)).is_none() {
|
||||
return Err("the revolved object has no fill or stroke".into());
|
||||
}
|
||||
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
|
||||
return faces(&path, b, fx_options(fx));
|
||||
}
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
|
||||
if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id))
|
||||
&& let Some(fx) = item.effects().get(index)
|
||||
{
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
|
||||
let surface = faces(&p, b, fx_options(fx))?;
|
||||
out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx }));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis.
|
||||
fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate::SolidMesh>, String> {
|
||||
let index = n
|
||||
|
||||
@@ -22,6 +22,9 @@ pub(crate) use source::{resolve, resolve_for};
|
||||
|
||||
pub(crate) mod shadow;
|
||||
|
||||
pub(crate) mod wireframe;
|
||||
pub use wireframe::three_d_wireframe_in;
|
||||
|
||||
pub const EXTRUDE: &str = "threeD.extrude";
|
||||
pub const INFLATE: &str = "threeD.inflate";
|
||||
pub const ROTATE: &str = "threeD.rotate";
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that
|
||||
//! can be edited on their own.
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId};
|
||||
use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath};
|
||||
use vectorcraft_three_d::Facet;
|
||||
|
||||
use super::{
|
||||
INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile,
|
||||
region, reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, view_options,
|
||||
};
|
||||
use crate::{GeomContext, is_geometry};
|
||||
|
||||
/// The most distinct edges one object's wireframe may have.
|
||||
pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000;
|
||||
|
||||
/// The grid points are rounded to before edges are compared, in points: two faces that share an
|
||||
/// edge give it once.
|
||||
const GRID: f64 = 1.0 / 1024.0;
|
||||
|
||||
const NO_EFFECT: &str = "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)";
|
||||
|
||||
/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D
|
||||
/// (Classic) forms), in document coordinates: the edges of every face the effect draws for the object as
|
||||
/// it is turned and projected, each edge once. Edges that meet end to end are joined into one path;
|
||||
/// a run that comes back to where it started is closed. A curved edge stays a curve. The geometry
|
||||
/// effects after the object's 3D effect reshape the wireframe as they reshape the drawn art. On a
|
||||
/// group or layer, the edges of the solids of all its members. An error when `n` has no visible 3D
|
||||
/// effect, when its solids can't be built, when the canvas draws the source art in their place, when
|
||||
/// they draw no face, or past [`MAX_WIRE_EDGES`] edges.
|
||||
pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, String> {
|
||||
if !crate::has_revolve(n) && !has_solid(n) {
|
||||
return Err(NO_EFFECT.into());
|
||||
}
|
||||
let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id));
|
||||
// The faces come from the effect whose art the canvas draws (Revolve first, as the canvas tries
|
||||
// it first). When neither draws, past their limits, the canvas shows the source art and there
|
||||
// is no surface to expand.
|
||||
let revolve = crate::has_revolve(n) && crate::revolve_art_in(doc, n).is_some();
|
||||
if !revolve && !(has_solid(n) && super::solid_art_in(doc, n).is_some()) {
|
||||
// The builders' own error says why, when there is one. Revolve's art is cut for Revolve,
|
||||
// as the canvas cuts it.
|
||||
if crate::has_revolve(n) {
|
||||
crate::revolve::revolve_face_paths(doc, &*resolve_for(doc, n, label, false)?)?;
|
||||
} else {
|
||||
solid_face_paths(doc, &*resolve(doc, n, label)?)?;
|
||||
}
|
||||
return Err(format!("{label} draws the source art for this object instead of its 3D surface"));
|
||||
}
|
||||
// Revolve's art is cut for Revolve whatever other 3D effect the object carries, as the canvas
|
||||
// cuts it ([`crate::revolve_art_in`]).
|
||||
let n = &*if revolve { resolve_for(doc, n, label, false)? } else { resolve(doc, n, label)? };
|
||||
let faces = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? };
|
||||
let faces = after_geometry(doc, n, faces, revolve);
|
||||
let wire = edges(&faces)?;
|
||||
if wire.is_empty() {
|
||||
return Err(format!("{label} draws no faces for this object"));
|
||||
}
|
||||
Ok(wire)
|
||||
}
|
||||
|
||||
/// `faces` reshaped by the visible geometry effects after the 3D effect of `n`'s own that drew them
|
||||
/// ([`reshaped`]): its first visible Revolve when `revolve` is set, else its first visible Extrude &
|
||||
/// Bevel, Inflate or Rotate.
|
||||
fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec<PathData>, revolve: bool) -> Vec<PathData> {
|
||||
let fx = &n.appearance.effects;
|
||||
let drawn = if revolve { fx.iter().position(|e| e.visible && crate::revolve::is_revolve(&e.id)) } else { solid(fx).map(|(i, _)| i) };
|
||||
let Some(index) = drawn else { return faces };
|
||||
reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) })
|
||||
}
|
||||
|
||||
/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), as
|
||||
/// they reshape the faces of the drawn surface ([`crate::reshape::reshape_leaves`]): each effect
|
||||
/// reshapes each face on its own, about the bounds all the faces have before it, and the faces keep
|
||||
/// to one budget of anchors together. `ctx` gives the object's id, the seed of a random Transform,
|
||||
/// and the document.
|
||||
pub(crate) fn reshaped(faces: Vec<PathData>, later: &[Effect], ctx: &GeomContext) -> Vec<PathData> {
|
||||
let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect();
|
||||
if after.is_empty() {
|
||||
return faces;
|
||||
}
|
||||
let id = NodeId(ctx.seed);
|
||||
let fill = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let mut art = Node::group(id, faces.into_iter().map(|f| Arc::new(Node::path(id, f, fill.clone()))).collect());
|
||||
crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc);
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c| out.extend(c.path_data().cloned()));
|
||||
out
|
||||
}
|
||||
|
||||
/// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect
|
||||
/// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer,
|
||||
/// the solids of its members, about one shared axis. The swatches and patterns named by a live-effect
|
||||
/// plug-in after a fill's or stroke's 3D effect are looked up in `doc`.
|
||||
fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, String> {
|
||||
if is_container(n) {
|
||||
let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?;
|
||||
let mut shapes = members(n, label_of(&fx.id))?;
|
||||
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
|
||||
return facet_paths(fx, &shapes);
|
||||
}
|
||||
let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id));
|
||||
let (path, rule) = object_profile(n, label)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]);
|
||||
}
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
for item in &paints {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?;
|
||||
out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx }));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and
|
||||
/// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's
|
||||
/// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out,
|
||||
/// as it is left out of the drawn art.
|
||||
fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result<Vec<PathData>, String> {
|
||||
let label = label_of(&fx.id);
|
||||
let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
let mut out = vec![];
|
||||
if is_plane(&fx.id) {
|
||||
for s in shapes {
|
||||
for item in &s.items {
|
||||
let (area, rule) = region(&s.path, s.rule, item);
|
||||
out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(Facet::path));
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let (parts, bounds) = solid_parts(shapes, b);
|
||||
let built: Vec<Result<Vec<Facet>, String>> = if fx.id == INFLATE {
|
||||
let shapes: Vec<(&PathData, vectorcraft_geom::FillRule)> = parts.iter().map(|(_, _, area, rule, ..)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
|
||||
} else {
|
||||
parts.iter().map(|(_, _, area, rule, ..)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
};
|
||||
for ((item, ..), f) in parts.iter().zip(built) {
|
||||
match f {
|
||||
Ok(f) => out.extend(f.iter().map(Facet::path)),
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A point on the [`GRID`].
|
||||
type Snap = (i64, i64);
|
||||
|
||||
fn snap(p: Point) -> Snap {
|
||||
((p.x / GRID).round() as i64, (p.y / GRID).round() as i64)
|
||||
}
|
||||
|
||||
fn reversed(c: CubicBez) -> CubicBez {
|
||||
CubicBez::new(c.p3, c.p2, c.p1, c.p0)
|
||||
}
|
||||
|
||||
/// The edges of `faces`, each once, joined end to end into paths ([`three_d_wireframe_in`]).
|
||||
pub(crate) fn edges(faces: &[PathData]) -> Result<Vec<PathData>, String> {
|
||||
let mut seen: HashSet<[Snap; 4]> = HashSet::new();
|
||||
let mut segments: Vec<CubicBez> = vec![];
|
||||
for sp in faces.iter().flat_map(|f| &f.subpaths) {
|
||||
for i in 0..sp.segment_count() {
|
||||
let mut c = sp.segment(i);
|
||||
if sp.segment_is_line(i) {
|
||||
c = CubicBez::new(c.p0, c.p0, c.p3, c.p3);
|
||||
}
|
||||
if [c.p0, c.p1, c.p2, c.p3].iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let key = [c.p0, c.p1, c.p2, c.p3].map(snap);
|
||||
// A segment that starts and ends on one grid point with its handles there too draws nothing.
|
||||
if key.iter().all(|k| *k == key[0]) {
|
||||
continue;
|
||||
}
|
||||
let back = [key[3], key[2], key[1], key[0]];
|
||||
if seen.contains(&back) || !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
if segments.len() >= MAX_WIRE_EDGES {
|
||||
return Err(format!("the wireframe has more than {MAX_WIRE_EDGES} edges"));
|
||||
}
|
||||
segments.push(c);
|
||||
}
|
||||
}
|
||||
Ok(chain(&segments))
|
||||
}
|
||||
|
||||
/// `segments` joined end to end: each path starts at the first segment not yet used and takes in
|
||||
/// the unused segments that meet its ends until none is left at either end, or it closes.
|
||||
fn chain(segments: &[CubicBez]) -> Vec<PathData> {
|
||||
let mut at: HashMap<Snap, Vec<usize>> = HashMap::new();
|
||||
for (i, c) in segments.iter().enumerate() {
|
||||
at.entry(snap(c.p0)).or_default().push(i);
|
||||
if snap(c.p3) != snap(c.p0) {
|
||||
at.entry(snap(c.p3)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut used = vec![false; segments.len()];
|
||||
// The next unused segment at `point`, turned to leave it (`leave`) or to arrive at it.
|
||||
let mut take = |used: &mut Vec<bool>, point: Snap, leave: bool| -> Option<CubicBez> {
|
||||
let list = at.get_mut(&point)?;
|
||||
while let Some(&i) = list.last() {
|
||||
if used.get(i).copied().unwrap_or(true) {
|
||||
list.pop();
|
||||
continue;
|
||||
}
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
let c = *segments.get(i)?;
|
||||
let starts = snap(c.p0) == point;
|
||||
return Some(if starts == leave { c } else { reversed(c) });
|
||||
}
|
||||
None
|
||||
};
|
||||
let mut out = vec![];
|
||||
for (i, first) in segments.iter().enumerate() {
|
||||
if used.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
let mut run: VecDeque<CubicBez> = VecDeque::from([*first]);
|
||||
// A path of one anchor has no segment (`SubPath::segment_count`), so a run of one segment
|
||||
// that comes back to its start stays open, with its two ends on one point.
|
||||
let closes = |run: &VecDeque<CubicBez>| run.len() > 1 && run.front().zip(run.back()).is_some_and(|(f, b)| snap(f.p0) == snap(b.p3));
|
||||
while !closes(&run) {
|
||||
let Some(end) = run.back().map(|c| snap(c.p3)) else { break };
|
||||
let Some(c) = take(&mut used, end, true) else { break };
|
||||
run.push_back(c);
|
||||
}
|
||||
while !closes(&run) {
|
||||
let Some(start) = run.front().map(|c| snap(c.p0)) else { break };
|
||||
let Some(c) = take(&mut used, start, false) else { break };
|
||||
run.push_front(c);
|
||||
}
|
||||
out.push(PathData::single(subpath(&run, closes(&run))));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `run`, segments that meet end to end, as one subpath; a closed one ends at its first anchor.
|
||||
fn subpath(run: &VecDeque<CubicBez>, closed: bool) -> SubPath {
|
||||
let mut anchors: Vec<Anchor> = vec![];
|
||||
if let Some(first) = run.front() {
|
||||
let h_in = if closed { run.back().map_or(first.p0, |b| b.p2) } else { first.p0 };
|
||||
anchors.push(Anchor::with_handles(first.p0, h_in, first.p1));
|
||||
}
|
||||
for (k, c) in run.iter().enumerate() {
|
||||
let h_out = run.get(k + 1).map_or(c.p3, |next| next.p1);
|
||||
anchors.push(Anchor::with_handles(c.p3, c.p2, h_out));
|
||||
}
|
||||
// A closed run's last anchor is its first, whose in handle already comes from the last segment.
|
||||
if closed {
|
||||
anchors.pop();
|
||||
}
|
||||
SubPath::new(anchors, closed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,235 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId};
|
||||
use vectorcraft_geom::{BezPath, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn square(x: f64, y: f64, size: f64) -> PathData {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((x, y));
|
||||
b.line_to((x + size, y));
|
||||
b.line_to((x + size, y + size));
|
||||
b.line_to((x, y + size));
|
||||
b.close_path();
|
||||
PathData::from_bezpath(&b)
|
||||
}
|
||||
|
||||
/// A filled 100 pt square without a stroke, with effect `id` and `params`.
|
||||
fn solid_square(id: &str, params: Value) -> Node {
|
||||
let mut n = Node::path(NodeId(1), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap());
|
||||
n
|
||||
}
|
||||
|
||||
/// Every segment of `wire`, each as its two ends on the grid.
|
||||
fn segments(wire: &[PathData]) -> Vec<(Snap, Snap)> {
|
||||
wire.iter()
|
||||
.flat_map(|p| &p.subpaths)
|
||||
.flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i)))
|
||||
.map(|c| (snap(c.p0), snap(c.p3)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bounds(paths: &[PathData]) -> Rect {
|
||||
paths.iter().filter_map(PathData::bounds).reduce(|a, b| a.union(b)).unwrap()
|
||||
}
|
||||
|
||||
/// The paths of the faces `art` draws (every leaf of the art).
|
||||
fn face_paths(art: &Node) -> Vec<PathData> {
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c| out.extend(c.path_data().cloned()));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_faces_that_share_an_edge_give_it_once() {
|
||||
let wire = edges(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)]).unwrap();
|
||||
let segs = segments(&wire);
|
||||
assert_eq!(segs.len(), 7, "{segs:?}");
|
||||
for (i, a) in segs.iter().enumerate() {
|
||||
for b in segs.iter().skip(i + 1) {
|
||||
assert!(a != b && (a.1, a.0) != *b, "{a:?} twice");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_of_edges_is_one_closed_path_and_a_curve_stays_a_curve() {
|
||||
let wire = edges(&[square(0.0, 0.0, 10.0)]).unwrap();
|
||||
assert_eq!(wire.len(), 1);
|
||||
assert!(wire[0].subpaths[0].closed);
|
||||
assert_eq!(wire[0].subpaths[0].anchors.len(), 4);
|
||||
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((0.0, 0.0));
|
||||
b.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0));
|
||||
b.close_path();
|
||||
let wire = edges(&[PathData::from_bezpath(&b)]).unwrap();
|
||||
let curved = wire.iter().flat_map(|p| &p.subpaths).any(|sp| (0..sp.segment_count()).any(|i| !sp.segment_is_line(i)));
|
||||
assert!(curved, "{wire:?}");
|
||||
}
|
||||
|
||||
/// A single curve that comes back to its start keeps its segment: it stays open, with two anchors on
|
||||
/// one point, since a path of one anchor has no segment.
|
||||
#[test]
|
||||
fn a_single_curve_that_comes_back_to_its_start_keeps_its_segment() {
|
||||
let p = Point::new(0.0, 0.0);
|
||||
let anchors = vec![Anchor::with_handles(p, p, Point::new(0.0, 10.0)), Anchor::with_handles(p, Point::new(10.0, 10.0), p)];
|
||||
let face = PathData::single(SubPath::new(anchors, false));
|
||||
assert_eq!(face.subpaths[0].segment_count(), 1);
|
||||
let wire = edges(&[face]).unwrap();
|
||||
assert_eq!(wire.len(), 1);
|
||||
let sp = &wire[0].subpaths[0];
|
||||
assert_eq!(sp.segment_count(), 1, "{sp:?}");
|
||||
assert!(!sp.segment_is_line(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_without_length_or_with_non_finite_points_are_left_out() {
|
||||
let point = PathData::single(SubPath::polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], false));
|
||||
let bad = PathData::single(SubPath::polyline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0)], false));
|
||||
assert!(edges(&[point, bad]).unwrap().is_empty());
|
||||
assert!(edges(&[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_turned_off_axis_shows_the_nine_edges_of_its_three_faces() {
|
||||
let n = solid_square(super::super::EXTRUDE, json!({}));
|
||||
let wire = three_d_wireframe_in(None, &n).unwrap();
|
||||
assert_eq!(segments(&wire).len(), 9, "{wire:?}");
|
||||
// The wireframe covers what the effect draws, in the same place.
|
||||
let art = super::super::solid_art(&n).unwrap();
|
||||
let (w, a) = (bounds(&wire), bounds(&face_paths(&art)));
|
||||
for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] {
|
||||
assert!((x - y).abs() < 1e-6, "{w:?} {a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_classic_wireframe_surface_expands_to_all_twelve_edges() {
|
||||
let n = solid_square(super::super::CLASSIC_EXTRUDE, json!({"surface": "wireframe"}));
|
||||
assert_eq!(segments(&three_d_wireframe_in(None, &n).unwrap()).len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_inflate_and_rotate_expand_too() {
|
||||
for id in [crate::REVOLVE, super::super::INFLATE, ROTATE] {
|
||||
let wire = three_d_wireframe_in(None, &solid_square(id, json!({}))).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert!(!wire.is_empty(), "{id}");
|
||||
assert!(wire.iter().flat_map(|p| &p.subpaths).flat_map(|sp| &sp.anchors).all(|a| a.p.x.is_finite() && a.p.y.is_finite()), "{id}");
|
||||
}
|
||||
// Rotate is one flat face: its outline.
|
||||
let plane = three_d_wireframe_in(None, &solid_square(ROTATE, json!({}))).unwrap();
|
||||
assert_eq!(segments(&plane).len(), 4);
|
||||
}
|
||||
|
||||
/// Revolve (Classic) and Rotate (Classic) expand to the edges of the faces they draw, in the same
|
||||
/// place.
|
||||
#[test]
|
||||
fn revolve_classic_and_rotate_classic_expand_too() {
|
||||
let n = solid_square(crate::CLASSIC_REVOLVE, json!({}));
|
||||
let wire = three_d_wireframe_in(None, &n).unwrap();
|
||||
assert!(!wire.is_empty());
|
||||
let art = crate::revolve_art(&n).unwrap();
|
||||
let (w, a) = (bounds(&wire), bounds(&face_paths(&art)));
|
||||
for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] {
|
||||
assert!((x - y).abs() < 1e-6, "{w:?} {a:?}");
|
||||
}
|
||||
// On a fill, with a geometry effect after it.
|
||||
let mut n = solid_square(crate::CLASSIC_REVOLVE, json!({}));
|
||||
let fx = n.appearance.effects.remove(0);
|
||||
for item in &mut n.appearance.items {
|
||||
if matches!(item, AppearanceItem::Fill(_)) {
|
||||
item.effects_mut().push(fx.clone());
|
||||
}
|
||||
}
|
||||
let plain = bounds(&three_d_wireframe_in(None, &n).unwrap());
|
||||
for item in &mut n.appearance.items {
|
||||
if matches!(item, AppearanceItem::Fill(_)) {
|
||||
item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap());
|
||||
}
|
||||
}
|
||||
let moved = bounds(&three_d_wireframe_in(None, &n).unwrap());
|
||||
assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{plain:?} {moved:?}");
|
||||
// Rotate (Classic) is one flat face: its outline.
|
||||
let plane = three_d_wireframe_in(None, &solid_square(super::super::CLASSIC_ROTATE, json!({}))).unwrap();
|
||||
assert_eq!(segments(&plane).len(), 4);
|
||||
}
|
||||
|
||||
/// The geometry effects after a 3D effect reshape each face on its own, as they reshape each face
|
||||
/// of the drawn surface: two faces that touch stay two outlines.
|
||||
#[test]
|
||||
fn later_geometry_effects_reshape_each_face_on_its_own() {
|
||||
let n = solid_square(super::super::EXTRUDE, json!({}));
|
||||
let fx = [crate::new_effect("path.offsetPath", &json!({"offset": 1.0})).unwrap()];
|
||||
let out = reshaped(vec![square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], &fx, &GeomContext::of(&n));
|
||||
assert_eq!(out.len(), 2, "{out:?}");
|
||||
let (a, b) = (out[0].bounds().unwrap(), out[1].bounds().unwrap());
|
||||
assert!((a.x0 + 1.0).abs() < 1e-3 && (a.x1 - 11.0).abs() < 1e-3, "{a:?}");
|
||||
assert!((b.x0 - 9.0).abs() < 1e-3 && (b.x1 - 21.0).abs() < 1e-3, "{b:?}");
|
||||
}
|
||||
|
||||
/// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface.
|
||||
#[test]
|
||||
fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() {
|
||||
for id in [super::super::EXTRUDE, crate::REVOLVE] {
|
||||
let mut n = solid_square(id, json!({}));
|
||||
let fx = n.appearance.effects.remove(0);
|
||||
for item in &mut n.appearance.items {
|
||||
if matches!(item, AppearanceItem::Fill(_)) {
|
||||
item.effects_mut().push(fx.clone());
|
||||
}
|
||||
}
|
||||
let plain = bounds(&three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}")));
|
||||
for item in &mut n.appearance.items {
|
||||
if matches!(item, AppearanceItem::Fill(_)) {
|
||||
item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap());
|
||||
}
|
||||
}
|
||||
let moved = bounds(&three_d_wireframe_in(None, &n).unwrap());
|
||||
assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{id}: {plain:?} {moved:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With two 3D effects, a geometry effect between them reshapes the faces of the one drawn only
|
||||
/// once: Revolve is drawn, so a Transform before it moves its profile and not its faces again.
|
||||
#[test]
|
||||
fn a_geometry_effect_before_the_drawn_3d_effect_is_not_applied_again() {
|
||||
let mut n = solid_square(crate::REVOLVE, json!({}));
|
||||
n.appearance.effects.insert(0, crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap());
|
||||
let alone = bounds(&three_d_wireframe_in(None, &n).unwrap());
|
||||
n.appearance.effects.insert(0, crate::new_effect(super::super::EXTRUDE, &json!({})).unwrap());
|
||||
let both = bounds(&three_d_wireframe_in(None, &n).unwrap());
|
||||
assert!((both.x0 - alone.x0).abs() < 1e-6 && (both.x1 - alone.x1).abs() < 1e-6, "{alone:?} {both:?}");
|
||||
}
|
||||
|
||||
/// A clipping group with Revolve and Rotate draws Revolve, which cuts the art's fills as one piece
|
||||
/// in the top fill's place. Its wireframe is cut the same way, so art with more fills than a 3D
|
||||
/// effect takes members still expands, to the edges of Revolve alone.
|
||||
#[test]
|
||||
fn a_clipping_group_with_revolve_and_rotate_expands_the_art_revolve_cuts() {
|
||||
let mut art = Node::path(NodeId(3), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let fill = art.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).cloned().unwrap();
|
||||
art.appearance.items = vec![fill; super::super::MAX_MEMBERS + 4];
|
||||
let mut clip = Node::path(NodeId(2), square(100.0, 100.0, 50.0), Appearance::default());
|
||||
if let vectorcraft_doc::NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let mut n = Node::new(NodeId(1), vectorcraft_doc::NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true });
|
||||
n.appearance.effects.push(crate::new_effect(crate::REVOLVE, &json!({})).unwrap());
|
||||
let alone = segments(&three_d_wireframe_in(None, &n).unwrap());
|
||||
n.appearance.effects.push(crate::new_effect(ROTATE, &json!({})).unwrap());
|
||||
assert!(crate::revolve_art_in(None, &n).is_some(), "the canvas draws Revolve");
|
||||
let both = segments(&three_d_wireframe_in(None, &n).unwrap());
|
||||
assert_eq!(both, alone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_without_a_visible_3d_effect_is_an_error() {
|
||||
let mut n = solid_square(super::super::EXTRUDE, json!({}));
|
||||
n.appearance.effects[0].visible = false;
|
||||
assert!(three_d_wireframe_in(None, &n).unwrap_err().contains("no 3D effect"));
|
||||
n.appearance.effects.clear();
|
||||
assert!(three_d_wireframe_in(None, &n).is_err());
|
||||
}
|
||||
@@ -32,6 +32,12 @@ const SHARP: f64 = 1.25;
|
||||
const HAIRPIN: f64 = 4.0;
|
||||
/// Rows of a mesh across the stroke (more where the gradient has stops).
|
||||
const MESH_ROWS: usize = 16;
|
||||
/// The most stops of the gradient [`gradient_meshes`] puts rows or columns at
|
||||
/// ([`Gradient::fitted`]: past it, the stops that change the colors least are left out).
|
||||
pub(crate) const MAX_MESH_STOPS: usize = 1024;
|
||||
/// The most points of one mesh [`gradient_meshes`] makes, unless a single pair of ribs needs more:
|
||||
/// a subpath with more ribs than leave room for a row at each stop becomes several meshes.
|
||||
pub(crate) const MAX_MESH_POINTS: usize = 1 << 20;
|
||||
/// The most of a subpath one column of a mesh along the stroke spans.
|
||||
const MESH_STEP: f64 = 1.0 / 32.0;
|
||||
/// The most of a subpath one slice spans while a width profile shapes the across gradient.
|
||||
@@ -513,26 +519,33 @@ fn mesh_marks(g: &Gradient) -> Vec<f64> {
|
||||
|
||||
/// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or
|
||||
/// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the
|
||||
/// stroke's outline), coloured as [`gradient_slices`] paint it.
|
||||
/// stroke's outline), coloured as [`gradient_slices`] paint it. The gradient's stops are fitted to
|
||||
/// [`MAX_MESH_STOPS`], and a subpath whose mesh would hold more than [`MAX_MESH_POINTS`] points is
|
||||
/// split into meshes of fewer ribs ([`rib_pieces`]).
|
||||
pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> {
|
||||
let g = &*g.unrolled();
|
||||
let fitted = g.fitted(MAX_MESH_STOPS);
|
||||
let across = st.gradient_mode == StrokeGradientMode::Across;
|
||||
let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32);
|
||||
let at_stops = mesh_marks(g);
|
||||
// Along: a column at each stop (colours are linear between columns), close enough to look smooth.
|
||||
let marks: Vec<f64> = if across { vec![] } else { at_stops.clone() };
|
||||
let marks: Vec<f64> = if across { vec![] } else { mesh_marks(&fitted) };
|
||||
let max_dt = match (across, st.profile.is_some()) {
|
||||
(false, _) => MESH_STEP,
|
||||
(true, true) => PROFILE_STEP,
|
||||
(true, false) => 1.0,
|
||||
};
|
||||
// Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop.
|
||||
let mut us: Vec<f64> = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(at_stops).collect() } else { vec![0.5] };
|
||||
let mut us: Vec<f64> = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(mesh_marks(&fitted)).collect() } else { vec![0.5] };
|
||||
us.sort_by(f64::total_cmp);
|
||||
us.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
let us = &us;
|
||||
let g = &*fitted;
|
||||
let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32);
|
||||
// A mesh is a point for each row (and its two edges) on each rib: a strip with more ribs than
|
||||
// that leaves room for is split into meshes of fewer ribs.
|
||||
let per_mesh = MAX_MESH_POINTS / (us.len() + 2);
|
||||
strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt)
|
||||
.into_iter()
|
||||
.filter(|ribs| ribs.len() > 1)
|
||||
.flat_map(|ribs| rib_pieces(ribs.len(), per_mesh).into_iter().map(move |r| ribs.get(r).unwrap_or_default().to_vec()))
|
||||
.map(|ribs| {
|
||||
let (rows, cols) = (us.len() + 1, ribs.len() - 1);
|
||||
// Each level across: a point on each rib and the gradient position there.
|
||||
@@ -569,3 +582,11 @@ pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradi
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The ribs (index ranges of `n`, at least two) of each mesh [`gradient_meshes`] makes of a strip:
|
||||
/// at most `per_mesh` ribs each (at least two), each starting on the last rib of the one before,
|
||||
/// so the meshes meet without a gap.
|
||||
pub(crate) fn rib_pieces(n: usize, per_mesh: usize) -> Vec<std::ops::Range<usize>> {
|
||||
let per_mesh = per_mesh.max(2);
|
||||
(0..n.saturating_sub(1)).step_by(per_mesh - 1).map(|start| start..(start + per_mesh).min(n)).collect()
|
||||
}
|
||||
|
||||
@@ -345,6 +345,35 @@ fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline Stroke's mesh across a stroke of many ribs, with a gradient of many stops repeated 100
|
||||
/// times: each mesh holds at most so many points, and every run still starts on its first color.
|
||||
#[test]
|
||||
fn a_mesh_across_many_ribs_and_stops_stays_bounded() {
|
||||
use vectorcraft_color::GradientSpread;
|
||||
let pts: Vec<(f64, f64)> = (0..1200).map(|i| (i as f64 * 10.0, if i % 2 == 0 { 0.0 } else { 10.0 })).collect();
|
||||
let bp = poly(&pts, false);
|
||||
let stops: Vec<GradientStop> = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(1.0 - i as f32 / 199.0))).collect();
|
||||
let g = Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops) };
|
||||
let mut st = stroke(4.0, StrokeGradientMode::Across);
|
||||
st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone())));
|
||||
let meshes = gradient_meshes(&bp, FillRule::NonZero, &st, &g);
|
||||
assert!(!meshes.is_empty());
|
||||
for m in &meshes {
|
||||
assert!(m.points.len() <= super::gradient::MAX_MESH_POINTS, "{} points", m.points.len());
|
||||
// A row at the start of each of the 100 runs, on every rib, is white.
|
||||
let light = m.points.iter().filter(|p| p.color.to_rgb()[0] > 0.97).count();
|
||||
assert!(light >= 100 * (m.cols as usize + 1), "{light} light points of {}", m.points.len());
|
||||
}
|
||||
// A strip of more ribs than one mesh has room for is split into meshes that share their end
|
||||
// ribs, every rib in one of them.
|
||||
use super::gradient::rib_pieces;
|
||||
assert_eq!(rib_pieces(5, 3), [0..3, 2..5]);
|
||||
assert_eq!(rib_pieces(4, 3), [0..3, 2..4]);
|
||||
let one = rib_pieces(2, 1000);
|
||||
assert_eq!((one.len(), one.first()), (1, Some(&(0..2))));
|
||||
assert_eq!(rib_pieces(3, 0), [0..2, 1..3]);
|
||||
}
|
||||
|
||||
/// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along
|
||||
/// the path, however many points lie that far: not as far as it is wide anywhere when more than
|
||||
/// 256 do.
|
||||
|
||||
@@ -78,6 +78,35 @@ fn a_butt_capped_open_path_is_measured_where_it_paints() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beveled_corners_are_measured_where_they_paint() {
|
||||
let mut tip = BezPath::new();
|
||||
tip.move_to((0.0, 100.0));
|
||||
tip.line_to((20.0, 0.0));
|
||||
tip.line_to((40.0, 100.0));
|
||||
tip.close_path();
|
||||
let mut chevron = BezPath::new();
|
||||
chevron.move_to((0.0, 100.0));
|
||||
chevron.line_to((20.0, 0.0));
|
||||
chevron.line_to((40.0, 100.0));
|
||||
// A rectangle with no width turns back at its top and bottom.
|
||||
let flat = vectorcraft_geom::shapes::rectangle(Rect::new(3.0, 100.0, 3.0, 150.0)).to_bezpath();
|
||||
let ps = paths();
|
||||
for (name, bp) in [("tip", &tip), ("chevron", &chevron), ("zigzag", &ps[1]), ("triangle", &ps[2]), ("flat", &flat)] {
|
||||
for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] {
|
||||
for (join, limit) in [(LineJoin::Bevel, 10.0), (LineJoin::Miter, 1.0), (LineJoin::Miter, 2.0), (LineJoin::Miter, 10.0)] {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 20.0);
|
||||
(st.cap, st.join, st.miter_limit) = (cap, join, limit);
|
||||
let painted = outline_region(&PathData::from_bezpath(bp), FillRule::NonZero, &st).to_bezpath().bounding_box();
|
||||
let bounds = st.bounds_on(bp, bp.bounding_box());
|
||||
for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] {
|
||||
assert!((got - want).abs() < 0.05, "{name} {cap:?} {join:?} {limit}: measured {bounds:?}, paints {painted:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_and_clicks_cover_what_every_stroke_paints() {
|
||||
let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })];
|
||||
|
||||
@@ -106,6 +106,7 @@ mod variablecmds;
|
||||
pub(crate) mod views;
|
||||
pub mod wand;
|
||||
pub mod webexport;
|
||||
mod wireframe;
|
||||
mod xform;
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -310,6 +311,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(draw2::specs());
|
||||
v.extend(xform::specs());
|
||||
v.extend(effectcmd::specs());
|
||||
v.extend(wireframe::specs());
|
||||
v.extend(pathops::specs());
|
||||
v.extend(compoundshape::specs());
|
||||
v.extend(typecmd::specs());
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
|
||||
|
||||
use super::fileio::{default_name, encode_with_warnings, isolated, write_file};
|
||||
use super::fileio::{Note, default_name, encode_with_notes, isolated, note_texts, write_file};
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, named_ids, targets};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -118,20 +118,22 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let css = format!("{}\n", sheet.text());
|
||||
let doc = &s.doc()?.doc;
|
||||
// Encode the pictures of rasterized art, and add each of their warnings once.
|
||||
// Encode the pictures of rasterized art, and add each of their warnings once. What CSS can't
|
||||
// describe is a loss.
|
||||
let mut images = Vec::new();
|
||||
let mut warnings = sheet.warnings.clone();
|
||||
let mut notes: Vec<Note> = sheet.warnings.iter().cloned().map(Note::loss).collect();
|
||||
for r in &sheet.rules {
|
||||
let Some(name) = &r.image else { continue };
|
||||
let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?;
|
||||
let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?;
|
||||
let (png, given) = encode_with_notes(&d, "png", &json!({}))?;
|
||||
images.push((name, png));
|
||||
for w in notes {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
for n in given {
|
||||
if !notes.iter().any(|x| x.text == n.text) {
|
||||
notes.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
let warnings = note_texts(¬es);
|
||||
let n = sheet.rules.len();
|
||||
match path {
|
||||
Some(path) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, fills and strokes at an opacity of 0 left out, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -131,14 +131,16 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S
|
||||
}
|
||||
|
||||
/// The Document Info categories of `d` (only `category` when given) from `info` (what
|
||||
/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the
|
||||
/// rows of its objects filled or stroked with a spot color, with a pattern and with a gradient.
|
||||
/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the
|
||||
/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a
|
||||
/// pattern and with a gradient.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sections(
|
||||
d: &Document,
|
||||
info: &Value,
|
||||
fonts: &BTreeSet<super::fonts::UsedFont>,
|
||||
images: &[ImageRow],
|
||||
brushes: &[(String, String)],
|
||||
repeats: &[(String, String)],
|
||||
spot_objects: &[(String, String)],
|
||||
pattern_objects: &[(String, String)],
|
||||
@@ -184,7 +186,7 @@ fn sections(
|
||||
rows
|
||||
}
|
||||
"graphicStyles" => names("graphicStyleNames"),
|
||||
"brushes" => names("brushNames"),
|
||||
"brushes" => brushes.to_vec(),
|
||||
"spotColors" => spot_objects.to_vec(),
|
||||
"patterns" => pattern_objects.to_vec(),
|
||||
"gradients" => gradient_objects.to_vec(),
|
||||
@@ -326,12 +328,18 @@ fn painted_runs<'a>(d: &Document, spots: &HashSet<&str>, n: &'a Node) -> Option<
|
||||
if !own && !t.runs.iter().any(|r| is_listed(&r.style.fill) || is_listed(&r.style.stroke)) {
|
||||
return None;
|
||||
}
|
||||
Some((t, drawn_runs(d, t)))
|
||||
}
|
||||
|
||||
/// The runs of type `t` that its layout draws: those with a glyph outline (list markers and ruby
|
||||
/// included) or an underline or strikethrough bar, laid out as the renderer lays it out.
|
||||
fn drawn_runs(d: &Document, t: &vectorcraft_doc::TextObject) -> BTreeSet<usize> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let resolved = d.inline_resolved(t);
|
||||
let lay = vectorcraft_text::layout(db, &resolved);
|
||||
let mut painted: BTreeSet<usize> = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect();
|
||||
painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run));
|
||||
Some((t, painted))
|
||||
painted
|
||||
}
|
||||
|
||||
/// The names a paint is listed by in a Document Info category: its spot colors, its pattern or its
|
||||
@@ -340,18 +348,17 @@ type PaintNames<'a> = dyn Fn(&Paint) -> Vec<String> + 'a;
|
||||
|
||||
/// The spot colors, patterns or gradients (`label`: [`spot_labels`], [`pattern_label`] or
|
||||
/// [`gradient_label`], each paint's names) that `n`'s own fills and strokes paint, each once. A
|
||||
/// hidden fill or stroke and a stroke with no width paint nothing, and neither does any fill or
|
||||
/// hidden fill or stroke, a fill or stroke at an opacity of 0 and a stroke with no width paint
|
||||
/// nothing, and neither does any fill or
|
||||
/// stroke of a guide (drawn in the guide color), of a compound path's member (`members`: the
|
||||
/// compound path's own fills and strokes paint it) or of type whose layout draws nothing
|
||||
/// (`painted_runs`). An image's fills and strokes paint only where an effect gives them a shape.
|
||||
/// With `ink` (the spot colors), a fill or stroke at an opacity of 0 prints no ink and is left out.
|
||||
fn own_paints(
|
||||
d: &Document,
|
||||
n: &Node,
|
||||
members: &HashSet<*const Node>,
|
||||
painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet<usize>)>,
|
||||
label: &PaintNames<'_>,
|
||||
ink: bool,
|
||||
) -> Vec<String> {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. })
|
||||
|| members.contains(&std::ptr::from_ref(n))
|
||||
@@ -366,8 +373,7 @@ fn own_paints(
|
||||
.then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().flat_map(|(_, _, item)| label(item.paint())).collect());
|
||||
let mut out: Vec<String> = vec![];
|
||||
for item in &n.appearance.items {
|
||||
let prints = item.opacity() > 0.0;
|
||||
if !item.visible() || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) || (ink && !prints) {
|
||||
if !item.visible() || item.opacity() <= 0.0 || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
for name in label(item.paint()) {
|
||||
@@ -384,6 +390,41 @@ fn one_label(label: fn(&Paint) -> Option<String>) -> impl Fn(&Paint) -> Vec<Stri
|
||||
move |p| label(p).into_iter().collect()
|
||||
}
|
||||
|
||||
/// The brushes `n`'s own strokes draw with, in the order of its appearance. A hidden stroke, a
|
||||
/// stroke at an opacity of 0, a stroke with no paint and a stroke with no width draw nothing, and neither does any stroke of a
|
||||
/// guide (drawn in the guide color) or of a compound path's member (`members`: the compound path's
|
||||
/// own strokes draw it). An image's strokes draw only where an effect gives them a shape, and only
|
||||
/// paths, compound paths, type and images draw strokes of their own.
|
||||
fn applied_brushes(d: &Document, n: &Node, members: &HashSet<*const Node>) -> Vec<String> {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. }) || members.contains(&std::ptr::from_ref(n)) {
|
||||
return vec![];
|
||||
}
|
||||
let brush = |item: &vectorcraft_doc::AppearanceItem| match item {
|
||||
vectorcraft_doc::AppearanceItem::Stroke(st) if item.visible() && st.opacity > 0.0 && !st.paint.is_none() && st.width > 0.0 => {
|
||||
st.brush.clone().filter(|b| !b.is_empty())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if matches!(n.kind, NodeKind::Image(_)) {
|
||||
return vectorcraft_render::effects::image_paint(Some(d), n).iter().filter_map(|(_, _, item)| brush(item)).collect();
|
||||
}
|
||||
let used: Vec<String> = n.appearance.items.iter().filter_map(brush).collect();
|
||||
if used.is_empty() {
|
||||
return used;
|
||||
}
|
||||
// A path or compound path with no segment gives its strokes nothing to follow, and neither does
|
||||
// type whose layout draws nothing (spaces and line breaks without an underline or strikethrough).
|
||||
let segments = |pd: &vectorcraft_geom::PathData| pd.subpaths.iter().any(|sp| sp.segment_count() > 0);
|
||||
let follows = match &n.kind {
|
||||
NodeKind::Path { .. } => n.path_data().is_some_and(segments),
|
||||
NodeKind::Compound { children, .. } => children.iter().filter_map(|c| c.path_data()).any(segments),
|
||||
NodeKind::Text(t) => !drawn_runs(d, t).is_empty(),
|
||||
// A brush on any other kind of object (a layer, a group, a ruler guide) is not listed.
|
||||
_ => false,
|
||||
};
|
||||
if follows { used } else { vec![] }
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.info";
|
||||
let category = str_param(p, "category");
|
||||
@@ -428,8 +469,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut spot = BTreeSet::new();
|
||||
let (spot_label, pattern, gradient) = (|p: &Paint| spot_labels(&spot_swatches, p), one_label(pattern_label), one_label(gradient_label));
|
||||
let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() };
|
||||
// `brushes` holds the Brushes panel's brushes, or with selectionOnly it holds the brushes that the selected objects' strokes use.
|
||||
let mut brushes: Vec<String> = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() };
|
||||
// `library` holds the Brushes panel's brushes, and `brushes` the brushes the visited objects' strokes draw with, each once,
|
||||
// in walk order.
|
||||
let library = vectorcraft_brush::library(d);
|
||||
let mut brushes: Vec<String> = vec![];
|
||||
// `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order.
|
||||
let mut repeats: Vec<Value> = vec![];
|
||||
let mut repeat_rows: Vec<(String, String)> = vec![];
|
||||
@@ -504,9 +547,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// The spot colors, the patterns and the gradients this object's fills and strokes paint, each once.
|
||||
let painted_runs = painted_runs(d, &spot_swatches, n);
|
||||
let mut used_spots = own_paints(d, n, &members, painted_runs.as_ref(), &spot_label, true);
|
||||
let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), &pattern, false);
|
||||
let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), &gradient, false);
|
||||
let mut used_spots = own_paints(d, n, &members, painted_runs.as_ref(), &spot_label);
|
||||
let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), &pattern);
|
||||
let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), &gradient);
|
||||
// The characters' own paints: an inline graphic draws no outline and does not take its
|
||||
// run's paint (a list marker set in its run does), and a stroke with no width paints
|
||||
// nothing.
|
||||
@@ -572,14 +615,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
styles.push(&g.name);
|
||||
}
|
||||
if selection_only {
|
||||
for item in &n.appearance.items {
|
||||
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item
|
||||
&& let Some(b) = &st.brush
|
||||
&& !brushes.contains(b)
|
||||
{
|
||||
brushes.push(b.clone());
|
||||
}
|
||||
for b in applied_brushes(d, n, &members) {
|
||||
if !brushes.contains(&b) {
|
||||
brushes.push(b);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Repeat(spec) = &n.kind {
|
||||
@@ -590,6 +628,17 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
|
||||
// Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two
|
||||
// brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type.
|
||||
let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect();
|
||||
let brush_entries: Vec<Value> =
|
||||
brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect();
|
||||
let brush_rows: Vec<(String, String)> = brushes
|
||||
.iter()
|
||||
.map(|name| {
|
||||
(name.clone(), kinds.get(name.as_str()).map_or_else(|| "missing from the Brushes panel".to_string(), |k| format!("{} brush", k.label())))
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({
|
||||
"document": document_summary(d),
|
||||
"objects": counts,
|
||||
@@ -604,6 +653,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"graphicStyles": d.graphic_styles.len(),
|
||||
"graphicStyleNames": styles,
|
||||
"brushNames": brushes,
|
||||
"brushes": brush_entries,
|
||||
"panelBrushNames": library.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
"repeatObjects": repeats,
|
||||
"characterStyles": d.char_styles.len(),
|
||||
"paragraphStyles": d.para_styles.len(),
|
||||
@@ -615,7 +666,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
// The document's name: its file's, else its title.
|
||||
out["document"]["name"] = json!(st.title());
|
||||
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category);
|
||||
let sections = sections(d, &out, &fonts, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category);
|
||||
if text {
|
||||
return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) }));
|
||||
}
|
||||
@@ -728,38 +779,129 @@ mod tests {
|
||||
assert_eq!(length(&mut s, json!({"selectionOnly": true})), 0.0);
|
||||
}
|
||||
|
||||
/// Document Info › Brushes lists the Brushes panel's brushes, and with Selection Only it lists
|
||||
/// the brushes the selected objects' strokes use, each once.
|
||||
/// Document Info › Brushes lists the brushes the art's strokes use, each once with its type, and
|
||||
/// leaves out the Brushes panel's brushes no art uses (`panelBrushNames` lists those). With
|
||||
/// Selection Only it lists the brushes the selected objects' strokes use.
|
||||
#[test]
|
||||
fn info_lists_the_brushes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let panel: Vec<Value> =
|
||||
s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().iter().map(|b| b["name"].clone()).collect();
|
||||
let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap();
|
||||
assert_eq!(i["brushNames"], json!(panel));
|
||||
let brushes = |s: &mut Session, p: Value| s.execute("document.info", &p).unwrap();
|
||||
// No art uses a brush: the category is empty.
|
||||
let i = brushes(&mut s, json!({"category": "brushes"}));
|
||||
assert_eq!((i["brushNames"].clone(), i["brushes"].clone(), i["panelBrushNames"].clone()), (json!([]), json!([]), json!(panel)));
|
||||
assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("brushes"), Some("Brushes")));
|
||||
let rows: Vec<Value> = panel.iter().map(|n| json!([n, ""])).collect();
|
||||
assert_eq!(i["sections"][0]["rows"], json!(rows));
|
||||
assert_eq!(i["sections"][0]["rows"], json!([]));
|
||||
let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let c = s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("brush.apply", &json!({"ids": [a], "name": "Charcoal"})).unwrap();
|
||||
s.execute("brush.apply", &json!({"ids": [b], "name": "Arrow"})).unwrap();
|
||||
s.execute("brush.apply", &json!({"ids": [b], "name": "Dots"})).unwrap();
|
||||
s.execute("brush.apply", &json!({"ids": [c], "name": "Charcoal"})).unwrap();
|
||||
let names =
|
||||
|s: &mut Session| s.execute("document.info", &json!({"selectionOnly": true, "category": "brushes"})).unwrap()["brushNames"].clone();
|
||||
// The whole document: the two brushes the art uses, each once, with their types; "3 pt. Round" (a Calligraphic
|
||||
// brush in the panel) is left out.
|
||||
let i = brushes(&mut s, json!({"category": "brushes"}));
|
||||
assert_eq!(i["brushNames"], json!(["Charcoal", "Dots"]));
|
||||
assert_eq!(i["brushes"], json!([{"name": "Charcoal", "type": "art"}, {"name": "Dots", "type": "scatter"}]));
|
||||
assert_eq!(i["sections"][0]["rows"], json!([["Charcoal", "Art brush"], ["Dots", "Scatter brush"]]));
|
||||
assert!(panel.contains(&json!("3 pt. Round")) && i["panelBrushNames"] == json!(panel));
|
||||
let text = brushes(&mut s, json!({"category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains("\nBRUSHES\nCharcoal: Art brush\nDots: Scatter brush\n") && !text.contains("3 pt. Round"), "{text}");
|
||||
assert!(!text.contains("OBJECTS"), "{text}");
|
||||
// Selection Only: the brushes the selected objects' strokes use.
|
||||
let selected = |s: &mut Session| brushes(s, json!({"selectionOnly": true, "category": "brushes"}));
|
||||
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
|
||||
assert_eq!(names(&mut s), json!(["Charcoal", "Arrow"]));
|
||||
assert_eq!(selected(&mut s)["brushNames"], json!(["Charcoal", "Dots"]));
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(names(&mut s), json!(["Arrow"]));
|
||||
let i = selected(&mut s);
|
||||
assert_eq!(
|
||||
(i["brushes"].clone(), i["sections"][0]["rows"].clone()),
|
||||
(json!([{"name": "Dots", "type": "scatter"}]), json!([["Dots", "Scatter brush"]]))
|
||||
);
|
||||
let text = brushes(&mut s, json!({"selectionOnly": true, "category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains("\nBRUSHES\nDots: Scatter brush\n") && !text.contains("Charcoal"), "{text}");
|
||||
// A stroke without a brush lists none.
|
||||
let plain = s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [plain]})).unwrap();
|
||||
assert_eq!(names(&mut s), json!([]));
|
||||
let text = s.execute("document.info", &json!({"category": "brushes", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains(&format!("\nBRUSHES\n{}\n", panel[0].as_str().unwrap())), "{text}");
|
||||
assert!(text.contains("\nCharcoal\n") && text.contains("\nArrow\n") && !text.contains("OBJECTS"), "{text}");
|
||||
assert_eq!(selected(&mut s)["brushNames"], json!([]));
|
||||
}
|
||||
|
||||
/// Document Info › Brushes leaves out brushes on strokes that draw nothing (hidden, at an
|
||||
/// opacity of 0, with no width or paint, a guide's, a compound path member's, a path's with no segment, type's that
|
||||
/// draws no characters, a group's own), lists a brush the Brushes panel does
|
||||
/// not hold with no type, and gives a name two panel brushes share the type the renderer uses.
|
||||
#[test]
|
||||
fn info_brushes_skip_undrawn_strokes_and_name_missing_brushes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let square = |s: &mut Session, x: f64| {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let (hidden, thin, guide, missing) = (square(&mut s, 0.0), square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0));
|
||||
let (empty, faint) = (square(&mut s, 80.0), square(&mut s, 200.0));
|
||||
s.execute("brush.apply", &json!({"ids": [hidden, thin, guide, missing, empty, faint], "name": "Charcoal"})).unwrap();
|
||||
let (p, q) = (square(&mut s, 100.0), square(&mut s, 120.0));
|
||||
s.execute("select.set", &json!({"ids": [p, q]})).unwrap();
|
||||
let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
// A compound path's member: the compound path's own stroke draws it.
|
||||
let NodeKind::Compound { children, .. } = &mut d.node_mut(NodeId(compound)).unwrap().kind else { panic!("not a compound path") };
|
||||
let member = &mut std::sync::Arc::make_mut(&mut children[0]).appearance;
|
||||
member.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
member.stroke_mut().unwrap().brush = Some("Dots".into());
|
||||
d.node_mut(NodeId(hidden)).unwrap().appearance.stroke_mut().unwrap().visible = false;
|
||||
d.node_mut(NodeId(thin)).unwrap().appearance.stroke_mut().unwrap().width = 0.0;
|
||||
// A stroke at an opacity of 0 draws nothing.
|
||||
d.node_mut(NodeId(faint)).unwrap().appearance.stroke_mut().unwrap().opacity = 0.0;
|
||||
if let NodeKind::Path { guide: g, .. } = &mut d.node_mut(NodeId(guide)).unwrap().kind {
|
||||
*g = true;
|
||||
}
|
||||
// A path with no segment: its stroke follows nothing.
|
||||
if let NodeKind::Path { path, .. } = &mut d.node_mut(NodeId(empty)).unwrap().kind {
|
||||
*path = vectorcraft_geom::PathData::default();
|
||||
}
|
||||
d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Gone".into());
|
||||
let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap();
|
||||
assert_eq!(i["brushes"], json!([{"name": "Gone", "type": null}]));
|
||||
assert_eq!(i["sections"][0]["rows"], json!([["Gone", "missing from the Brushes panel"]]));
|
||||
// A stroke with no paint draws nothing.
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().paint = Paint::None;
|
||||
assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([]));
|
||||
// Two panel brushes with one name: the type is the one the renderer draws with, the last one's.
|
||||
let twin = square(&mut s, 140.0);
|
||||
s.execute("brush.apply", &json!({"ids": [twin], "name": "Charcoal"})).unwrap();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let mut lib = vectorcraft_brush::library(d);
|
||||
let kinds: Vec<vectorcraft_brush::BrushKind> =
|
||||
["Charcoal", "Dots"].iter().map(|n| lib.iter().find(|b| b.name == *n).unwrap().kind.clone()).collect();
|
||||
lib.extend(kinds.into_iter().map(|kind| vectorcraft_brush::Brush { name: "Twin".into(), kind }));
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
d.node_mut(NodeId(twin)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Twin".into());
|
||||
assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "scatter"}]));
|
||||
// Type: a brushed stroke on drawn characters is listed, one on spaces alone is not.
|
||||
let blank = s.execute("text.create", &json!({"x": 10, "y": 60, "text": " "})).unwrap()["id"].as_u64().unwrap();
|
||||
let word = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
for (id, name) in [(blank, "Dots"), (word, "Arrow")] {
|
||||
let ap = &mut d.node_mut(NodeId(id)).unwrap().appearance;
|
||||
ap.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
ap.stroke_mut().unwrap().brush = Some(name.into());
|
||||
}
|
||||
let names = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushNames"].clone();
|
||||
assert_eq!(names, json!(["Twin", "Arrow"]));
|
||||
// A group's own stroke: the group draws its members.
|
||||
let (m1, m2) = (square(&mut s, 160.0), square(&mut s, 180.0));
|
||||
s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let ap = &mut d.node_mut(NodeId(group)).unwrap().appearance;
|
||||
ap.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
ap.stroke_mut().unwrap().brush = Some("Leaves".into());
|
||||
let names = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushNames"].clone();
|
||||
assert_eq!(names, json!(["Twin", "Arrow"]));
|
||||
}
|
||||
|
||||
/// Document Info › Repeat Objects lists each Repeat object by name with its kind and the number
|
||||
@@ -870,6 +1012,11 @@ mod tests {
|
||||
s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Dots"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap();
|
||||
// Nor does a pattern fill at an opacity of 0.
|
||||
let clear = square(&mut s, 220.0);
|
||||
s.execute("paint.setStroke", &json!({"ids": [clear], "none": true})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [clear], "swatch": "Dots"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [clear], "index": 0, "opacity": 0})).unwrap();
|
||||
// A type object with no characters paints nothing either.
|
||||
let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone();
|
||||
s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Dots"})).unwrap();
|
||||
@@ -930,7 +1077,7 @@ mod tests {
|
||||
assert_eq!(i["patternNames"], json!(["Dots"]));
|
||||
// Objects counts the objects that Pattern Objects lists.
|
||||
assert_eq!(i["objects"]["patterns"].as_u64(), Some(5), "{}", i["objects"]);
|
||||
s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [d, e, clear, empty, blank, guide, compound, image.0]})).unwrap();
|
||||
let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap();
|
||||
assert!(i["objects"]["patterns"].is_null() && i["patternObjects"] == json!([]), "{}", i["objects"]);
|
||||
let i = s.execute("document.info", &json!({"category": "patterns"})).unwrap();
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
doc_compare
|
||||
),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so is a data set's image value outside them; a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| {
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so are a data set's image value and a linked JavaScript file in svg_scripts outside them (a URL is kept); a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| {
|
||||
let st = s.doc()?;
|
||||
let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st));
|
||||
Ok(serde_json::to_value(&*d).unwrap_or(Value::Null))
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -194,10 +194,12 @@ impl Expandable {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && !c.appearance.stroke_paint().is_none(),
|
||||
};
|
||||
e.gradient |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
|
||||
};
|
||||
// Once one is found, the next path is not measured.
|
||||
e.gradient = e.gradient
|
||||
|| match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && gradient_expands(d, c),
|
||||
};
|
||||
e.fill |= e.gradient;
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())),
|
||||
@@ -222,6 +224,43 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether path `l` has a gradient fill that Fill expands ([`expand_fills`]): one that repeats or
|
||||
/// reflects stays a gradient when its box needs more runs than are written ([`padded_on`]). For a
|
||||
/// path that live-effect plug-ins paint, whether a piece of the art they paint has one
|
||||
/// ([`expand_pieces`]).
|
||||
fn gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
|
||||
return false;
|
||||
}
|
||||
if !vectorcraft_render::effects::has_painted_effects(l) {
|
||||
return piece_gradient_expands(d, l);
|
||||
}
|
||||
let Some(art) = vectorcraft_render::effects::reshape(Some(d), l, None) else { return false };
|
||||
let mut any = false;
|
||||
art.walk(&mut |c| {
|
||||
any = any
|
||||
|| (matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& !c.is_ruler_guide()
|
||||
&& c.appearance.items.iter().any(|i| gradient_fill(i).is_some())
|
||||
&& piece_gradient_expands(d, c));
|
||||
});
|
||||
any
|
||||
}
|
||||
|
||||
/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]).
|
||||
fn piece_gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) {
|
||||
return l.appearance.items.iter().any(|i| gradient_fill(i).is_some());
|
||||
}
|
||||
let reshaped = reshaped(d, l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let Some((path, _)) = node_path(l) else { return false };
|
||||
let Some(bounds) = path.bounds() else { return false };
|
||||
l.appearance.items.iter().filter(|i| gradient_fill(i).is_some()).any(|item| {
|
||||
fill_shape(d, l, item, &path, bounds).is_some_and(|(_, _, b, moved)| gradient_fill(&moved).is_some_and(|(_, g)| padded_on(g, b).is_some()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The definition and placement of a visible pattern fill whose pattern `d` defines.
|
||||
fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> {
|
||||
match i {
|
||||
@@ -476,8 +515,9 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
|
||||
} else {
|
||||
// A gradient that repeats or reflects expands as its stops repeated over the box.
|
||||
let (g, runs) = padded_on(g, bounds);
|
||||
// A gradient that repeats or reflects expands as its stops repeated over the box (and
|
||||
// stays a gradient when the box needs more runs than are written).
|
||||
let (g, runs) = padded_on(g, bounds)?;
|
||||
let g = &*g;
|
||||
let frame = Frame::new(g, bounds);
|
||||
match to {
|
||||
@@ -1244,26 +1284,32 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
|
||||
Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32))
|
||||
}
|
||||
|
||||
/// For stops repeated by [`padded_on`]: where along the vector (0 to 1) their first run starts and
|
||||
/// how many runs lie evenly from there to 1.
|
||||
type Runs = Option<(f64, usize)>;
|
||||
|
||||
/// `g` placed on box `bounds` as a gradient that pads past its ends: a linear, radial or diamond
|
||||
/// gradient that repeats or reflects with its stops repeated as many times as the box needs
|
||||
/// ([`vectorcraft_color::Gradient::padded_over`]), a conic one with its stops repeated around the
|
||||
/// turn ([`vectorcraft_color::Gradient::unrolled`]); others as they are. With it, for the repeated
|
||||
/// stops, where along the vector (0 to 1) their first run starts and how many runs lie evenly from
|
||||
/// there to 1.
|
||||
fn padded_on(g: &GradientPaint, bounds: Rect) -> (std::borrow::Cow<'_, GradientPaint>, Option<(f64, usize)>) {
|
||||
/// there to 1. `None` when the box needs more runs than are written
|
||||
/// ([`vectorcraft_color::Padded::capped`]): the gradient stays.
|
||||
fn padded_on(g: &GradientPaint, bounds: Rect) -> Option<(std::borrow::Cow<'_, GradientPaint>, Runs)> {
|
||||
use std::borrow::Cow;
|
||||
if !g.gradient.is_spread() {
|
||||
return (Cow::Borrowed(g), None);
|
||||
return Some((Cow::Borrowed(g), None));
|
||||
}
|
||||
let geom = g.resolve(bounds);
|
||||
match g.gradient.padded_over(&geom, bounds) {
|
||||
Some(p) if p.capped => None,
|
||||
Some(p) => {
|
||||
let runs = Some((p.first, p.runs));
|
||||
(Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs)
|
||||
Some((Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs))
|
||||
}
|
||||
None => {
|
||||
let runs = Some((0.0, g.gradient.repeat_count() as usize));
|
||||
(Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs)
|
||||
Some((Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ use serde_json::{Value, json};
|
||||
|
||||
pub use action_batch::batch_overwrites_one_file;
|
||||
pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts};
|
||||
pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub(crate) use export::with_artboard_fills;
|
||||
pub(crate) use export::{export_crop, isolated};
|
||||
|
||||
@@ -241,6 +241,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) ->
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
enc.add_setting_notes(out.setting_notes);
|
||||
for img in out.images {
|
||||
if !enc.linked.iter().any(|l| l.name == img.name) {
|
||||
enc.linked.push(vectorcraft_svg::LinkedImage { name: img.name, bytes: Arc::new(img.bytes) });
|
||||
|
||||
@@ -74,18 +74,104 @@ pub struct Encoded {
|
||||
pub linked: Vec<vectorcraft_svg::LinkedImage>,
|
||||
/// Image maps, each written beside its image under the image's name (raster `imageMap`).
|
||||
pub maps: Vec<imagemap::Map>,
|
||||
/// The encoder's warnings (features approximated or left out, options not applied yet).
|
||||
/// The encoder's warnings: what the file leaves out of the document or writes differently
|
||||
/// ([`NoteKind::Loss`]).
|
||||
pub warnings: Vec<String>,
|
||||
/// The encoder's notes on the options: options not applied yet or applied another way, and
|
||||
/// what an option turns off ([`NoteKind::Setting`]). A reply gives them after the warnings
|
||||
/// ([`Self::notes`]).
|
||||
pub setting_notes: Vec<String>,
|
||||
/// What joins the file's stem and the artboard name when there is a file per artboard
|
||||
/// (default `-`; EPS uses `_`, as the reference app names them).
|
||||
pub joiner: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// What a note an export gives with its file is about.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NoteKind {
|
||||
/// Something of the document (its art, type, layers or artboards) that the file leaves out or
|
||||
/// that reaches it changed: the format, its version or a limit can't hold it, or the export
|
||||
/// couldn't write it.
|
||||
Loss,
|
||||
/// An option the export didn't apply or applied another way while the art reaches the file as
|
||||
/// it looks, or something left out because an option turns it off.
|
||||
Setting,
|
||||
}
|
||||
|
||||
/// A note an export gives with its file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Note {
|
||||
/// What the note is about.
|
||||
pub kind: NoteKind,
|
||||
/// The note, in English.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
/// A note about what the file loses ([`NoteKind::Loss`]).
|
||||
pub fn loss(text: impl Into<String>) -> Self {
|
||||
Self { kind: NoteKind::Loss, text: text.into() }
|
||||
}
|
||||
|
||||
/// A note on the options ([`NoteKind::Setting`]).
|
||||
pub fn setting(text: impl Into<String>) -> Self {
|
||||
Self { kind: NoteKind::Setting, text: text.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The texts of `notes` in the order a reply gives them: the losses first, then the notes on the
|
||||
/// options, each kind in the order given (the status bar names the first).
|
||||
pub fn note_texts(notes: &[Note]) -> Vec<String> {
|
||||
let of = |kind| notes.iter().filter(move |n| n.kind == kind).map(|n| n.text.clone());
|
||||
of(NoteKind::Loss).chain(of(NoteKind::Setting)).collect()
|
||||
}
|
||||
|
||||
/// Add `note` to `notes` unless a note with its text is there already.
|
||||
pub(super) fn add_note(notes: &mut Vec<Note>, note: Note) {
|
||||
if !notes.iter().any(|n| n.text == note.text) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoded {
|
||||
pub(super) fn one(bytes: Vec<u8>) -> Self {
|
||||
Self { files: vec![(None, bytes)], ..Self::default() }
|
||||
}
|
||||
|
||||
/// The file `bytes` with `notes`, each in the list of its kind, in the order given.
|
||||
pub(super) fn one_with_notes(bytes: Vec<u8>, notes: Vec<Note>) -> Self {
|
||||
let mut enc = Self::one(bytes);
|
||||
for n in notes {
|
||||
match n.kind {
|
||||
NoteKind::Loss => enc.warnings.push(n.text),
|
||||
NoteKind::Setting => enc.setting_notes.push(n.text),
|
||||
}
|
||||
}
|
||||
enc
|
||||
}
|
||||
|
||||
/// Add each of `texts` to [`Self::setting_notes`] unless a note with its text is there already.
|
||||
pub(super) fn add_setting_notes(&mut self, texts: impl IntoIterator<Item = String>) {
|
||||
for t in texts {
|
||||
if !self.warnings.contains(&t) && !self.setting_notes.contains(&t) {
|
||||
self.setting_notes.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every note with its kind: the warnings, then the notes on the options that aren't also
|
||||
/// warnings.
|
||||
pub fn kinded_notes(&self) -> Vec<Note> {
|
||||
let settings = self.setting_notes.iter().filter(|t| !self.warnings.contains(t)).map(Note::setting);
|
||||
self.warnings.iter().map(Note::loss).chain(settings).collect()
|
||||
}
|
||||
|
||||
/// The notes' texts in the order a reply gives them ([`note_texts`]): the warnings, then the
|
||||
/// notes on the options, each once.
|
||||
pub fn notes(&self) -> Vec<String> {
|
||||
note_texts(&self.kinded_notes())
|
||||
}
|
||||
|
||||
/// Every file to write for the destination `path` (a path or a file name): the file itself
|
||||
/// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard
|
||||
/// among several, as Save's master file, is the file itself), then the linked images beside it
|
||||
@@ -128,13 +214,14 @@ impl Encoded {
|
||||
out
|
||||
}
|
||||
|
||||
/// The one file and the warnings, when the export wrote nothing else.
|
||||
fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
/// The one file and the notes, when the export wrote nothing else.
|
||||
fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
if !self.linked.is_empty() || !self.maps.is_empty() {
|
||||
return Err(bad(C, format!("{} with linked images or an image map writes several files: use document.export", f.label)));
|
||||
}
|
||||
let notes = self.kinded_notes();
|
||||
match <[_; 1]>::try_from(self.files) {
|
||||
Ok([(_, bytes)]) => Ok((bytes, self.warnings)),
|
||||
Ok([(_, bytes)]) => Ok((bytes, notes)),
|
||||
Err(_) => Err(bad(C, format!("{} writes one file per artboard here: use document.export, or name one artboard", f.label))),
|
||||
}
|
||||
}
|
||||
@@ -419,8 +506,14 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
|
||||
/// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet,
|
||||
/// features approximated or left out; every format that draws type: fonts written in the fallback
|
||||
/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame).
|
||||
/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame), what
|
||||
/// the file loses first ([`note_texts`]).
|
||||
pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
encode_with_notes(doc, format, p).map(|(bytes, notes)| (bytes, note_texts(¬es)))
|
||||
}
|
||||
|
||||
/// Like [`encode_with_warnings`], with each note's kind ([`Encoded::kinded_notes`]).
|
||||
pub fn encode_with_notes(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let enc = encode_all(doc, f.id, p)?;
|
||||
// The error for a version 7 .ai of several artboards names the legacy .ai.
|
||||
@@ -476,7 +569,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
/// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON
|
||||
/// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else
|
||||
/// one named after the document) with that path's extension when the plug-in lists it, else its
|
||||
/// first. Its warnings come back with the file.
|
||||
/// first. Its warnings come back with the file, as losses (the plug-in gives them no kind).
|
||||
fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
use vectorcraft_plugins::{Error, export};
|
||||
let doc = &*doc.without_edit_modes();
|
||||
@@ -539,8 +632,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
"hpgl" => return super::hpgl::encode(doc, p, use_artboards),
|
||||
"ai" => return super::save::encode_ai(C, f, doc, p),
|
||||
"pdf" => {
|
||||
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
||||
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
|
||||
let (bytes, notes) = super::pdf::encode(C, doc, p)?;
|
||||
return Ok(Encoded::one_with_notes(bytes, notes));
|
||||
}
|
||||
"png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" | "psd" => {
|
||||
let o: RasterOptions = options(f, p)?;
|
||||
@@ -607,7 +700,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
let regions: Vec<Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
if format == RasterFormat::WebP && o.lossless == Some(false) {
|
||||
enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
|
||||
enc.setting_notes.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
|
||||
}
|
||||
return Ok(enc);
|
||||
}
|
||||
|
||||
@@ -382,6 +382,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> {
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
enc.add_setting_notes(out.setting_notes);
|
||||
}
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
Ok(enc)
|
||||
|
||||
@@ -42,7 +42,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }),
|
||||
};
|
||||
let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.warnings }));
|
||||
let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.notes() }));
|
||||
if main.len() > 1 {
|
||||
out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect();
|
||||
}
|
||||
@@ -69,7 +69,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.insert("_displayDocTitle".into(), json!(display_doc_title));
|
||||
}
|
||||
let enc = encode_all(doc, f.id, &q)?;
|
||||
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
|
||||
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.notes() }))
|
||||
}
|
||||
|
||||
/// The document an export of `st` in format `f` writes ([`export_source`]): a `.ai` or `.ait`
|
||||
|
||||
@@ -116,6 +116,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) ->
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
enc.add_setting_notes(out.setting_notes);
|
||||
}
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
|
||||
@@ -136,10 +136,12 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
}
|
||||
let images = see_through_images(&d, mode);
|
||||
if !images.is_empty() {
|
||||
note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED });
|
||||
if mode == SeeThrough::Flatten {
|
||||
note(&mut enc.warnings, FLATTENED);
|
||||
flatten_images(&mut d, &images)?;
|
||||
} else {
|
||||
// A note on the options: `transparentImages: omit` leaves them out.
|
||||
enc.add_setting_notes([OMITTED.to_string()]);
|
||||
for (_, id) in images {
|
||||
let _ = d.remove(id); // Inside an image left out before: gone already.
|
||||
}
|
||||
@@ -150,6 +152,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
for w in placed.iter().chain(&out.warnings) {
|
||||
note(&mut enc.warnings, w);
|
||||
}
|
||||
enc.add_setting_notes(out.setting_notes);
|
||||
enc.files.push((Some(b), out.bytes));
|
||||
}
|
||||
// Expanding raster effects and flattening images draw the art, and an image the renderer
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, S
|
||||
|
||||
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
|
||||
use super::super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||||
use super::{ArtboardPick, FormatOption, write_or_return};
|
||||
use super::{ArtboardPick, FormatOption, Note, note_texts, write_or_return};
|
||||
use crate::EngineError;
|
||||
|
||||
const C: &str = "document.exportPdf";
|
||||
@@ -284,21 +284,22 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str =
|
||||
/// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]).
|
||||
pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings";
|
||||
|
||||
/// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered
|
||||
/// Encode `doc` as PDF with the options in `p` → (bytes, notes). Raster effects are rendered
|
||||
/// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself
|
||||
/// when the PDF has every artboard (a PDF of some reopens as just those).
|
||||
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
encode_at(cmd, doc, p, None)
|
||||
}
|
||||
|
||||
/// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing.
|
||||
pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option<i64>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option<i64>) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
// Keep the editable object tree independent of preview fallbacks used by the drawn pages.
|
||||
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
|
||||
warnings.extend(drawn_warnings);
|
||||
warnings.extend(overflow_warning(cmd, &full, p)?);
|
||||
Ok((bytes, warnings))
|
||||
let (full, placed) = crate::cmd::place::document::full_documents(doc);
|
||||
let mut notes: Vec<Note> = placed.into_iter().map(Note::loss).collect();
|
||||
let (bytes, drawn) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
|
||||
notes.extend(drawn);
|
||||
notes.extend(overflow_warning(cmd, &full, p)?.map(Note::loss));
|
||||
Ok((bytes, notes))
|
||||
}
|
||||
|
||||
/// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and
|
||||
@@ -321,7 +322,7 @@ pub(super) fn encode_carrying(
|
||||
p: &Value,
|
||||
native: impl FnOnce() -> Result<Vec<u8>>,
|
||||
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
|
||||
) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
encode_carrying_at(cmd, doc, p, None, native, private)
|
||||
}
|
||||
|
||||
@@ -334,7 +335,7 @@ fn encode_carrying_at(
|
||||
modified: Option<i64>,
|
||||
native: impl FnOnce() -> Result<Vec<u8>>,
|
||||
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
|
||||
) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?;
|
||||
// The pages' background colors, behind the art (the editing data keeps them as colors).
|
||||
let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect());
|
||||
@@ -345,21 +346,22 @@ fn encode_carrying_at(
|
||||
if opts.settings.thumbnails {
|
||||
opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
}
|
||||
let mut warnings = vec![];
|
||||
// The status bar shows the first warning, so this one goes ahead of the flattening note.
|
||||
let mut notes = vec![];
|
||||
// A loss, ahead of the flattening note: the PDF/X pages leave out the bleed that Document
|
||||
// Setup gives.
|
||||
if opts.settings.leaves_out_bleed(doc) {
|
||||
warnings.push(BLEED_LEFT_OUT.to_string());
|
||||
notes.push(Note::loss(BLEED_LEFT_OUT));
|
||||
}
|
||||
if opts.settings.preserve_editing {
|
||||
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
|
||||
opts.native = Some(native()?);
|
||||
if let Some((data, lost)) = private()? {
|
||||
opts.private_data = Some(data);
|
||||
warnings.extend(lost);
|
||||
notes.extend(lost.into_iter().map(Note::loss));
|
||||
}
|
||||
} else {
|
||||
opts.settings.preserve_editing = false;
|
||||
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
|
||||
notes.push(Note::setting(EDITING_NEEDS_EVERY_ARTBOARD));
|
||||
}
|
||||
}
|
||||
let set = &opts.settings;
|
||||
@@ -370,12 +372,13 @@ fn encode_carrying_at(
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
if flattened {
|
||||
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
|
||||
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
|
||||
notes.push(Note::loss(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")));
|
||||
}
|
||||
let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
warnings.extend(r.warnings);
|
||||
warnings.extend(crate::cmd::fonts::substitution_warning(doc));
|
||||
Ok((r.bytes, warnings))
|
||||
notes.extend(r.warnings.into_iter().map(Note::loss));
|
||||
notes.extend(r.setting_notes.into_iter().map(Note::setting));
|
||||
notes.extend(crate::cmd::fonts::substitution_warning(doc).map(Note::loss));
|
||||
Ok((r.bytes, notes))
|
||||
}
|
||||
|
||||
/// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty.
|
||||
@@ -479,8 +482,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
q = json!({ "_displayDocTitle": display_doc_title });
|
||||
}
|
||||
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?;
|
||||
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
|
||||
let (bytes, notes) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?;
|
||||
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": note_texts(¬es) }))
|
||||
}
|
||||
|
||||
/// `(path, value)` for every leaf of `v` that differs from `default` (`compression.color.ppi`).
|
||||
@@ -512,7 +515,7 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let set = settings_with(Q, p, saved)?;
|
||||
let diff = changes(&set, &preset_settings(Q, p, saved)?);
|
||||
let warnings = match s.active() {
|
||||
Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1,
|
||||
Some(st) if bool_or(p, "includeDocument", false) => note_texts(&encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1),
|
||||
_ => set.warnings(),
|
||||
};
|
||||
let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
|
||||
@@ -752,12 +752,14 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
|
||||
carries_editing.set(true);
|
||||
Ok(Some((data, resolved_warnings)))
|
||||
};
|
||||
let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?;
|
||||
warnings.extend(placed_warnings);
|
||||
let (bytes, notes) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?;
|
||||
let mut enc = Encoded::one_with_notes(bytes, notes);
|
||||
enc.warnings.extend(placed_warnings);
|
||||
// A note on the options: `pdfCompatible: false` writes blank pages.
|
||||
if !pdf_compatible {
|
||||
warnings.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string());
|
||||
enc.setting_notes.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string());
|
||||
}
|
||||
Ok(Encoded { warnings, ..Encoded::one(bytes) })
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// Snapshot the active document for `plan` (stamping File Info's dates on the snapshot when the
|
||||
@@ -845,8 +847,8 @@ impl SaveJob {
|
||||
}
|
||||
|
||||
/// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with
|
||||
/// the warnings (what the format loses first, then the encoder's own notes: PDF options not
|
||||
/// applied yet…, then the plan's notes on its params).
|
||||
/// the notes (what the format loses first, then the encoder's own notes: PDF options not
|
||||
/// applied yet…, then the plan's notes on its params, which are notes on the options).
|
||||
pub fn encode(&self) -> Result<Encoded> {
|
||||
let mut enc = self.encode_one(&self.doc)?;
|
||||
for &b in &self.boards {
|
||||
@@ -854,7 +856,7 @@ impl SaveJob {
|
||||
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
|
||||
}
|
||||
enc.warnings.splice(0..0, self.format_losses());
|
||||
enc.warnings.extend(self.plan.notes.iter().cloned());
|
||||
enc.setting_notes.extend(self.plan.notes.iter().cloned());
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
@@ -905,9 +907,8 @@ impl SaveJob {
|
||||
/// order: when `stop` returns a value, nothing is written and that value is returned.
|
||||
pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option<Value>, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let (plan, doc) = (&self.plan, &*self.doc);
|
||||
let mut enc = self.encode()?;
|
||||
let warnings = std::mem::take(&mut enc.warnings);
|
||||
let extra = json!({ "format": plan.format.id, "warnings": warnings });
|
||||
let enc = self.encode()?;
|
||||
let extra = json!({ "format": plan.format.id, "warnings": enc.notes() });
|
||||
let Some(path) = &plan.path else {
|
||||
let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?;
|
||||
out["name"] = json!(plan.name);
|
||||
|
||||
@@ -17,12 +17,12 @@ use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param};
|
||||
use super::encode::single_artboard;
|
||||
use super::encode::{add_note, single_artboard};
|
||||
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge,
|
||||
unique_file_names_keeping_dots, with_black_output, writable, write_file,
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge,
|
||||
note_texts, unique_file_names_keeping_dots, with_black_output, writable, write_file,
|
||||
};
|
||||
|
||||
const C: &str = "document.exportForScreens";
|
||||
@@ -1021,7 +1021,8 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map<Stri
|
||||
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
|
||||
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
|
||||
/// so do different files that would share a name, with or without the `name` pattern. The reply's
|
||||
/// `warnings` lists every warning the encoders give for the files, each once.
|
||||
/// `warnings` lists every warning the encoders give for the files, each once, what the files lose
|
||||
/// first ([`super::note_texts`]).
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_passwords(p)?;
|
||||
let o = ScreenParams::of(p)?;
|
||||
@@ -1140,18 +1141,16 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let mut out = Output::default();
|
||||
// The encoders' warnings for every file, each once.
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
// The encoders' notes for every file, each once.
|
||||
let mut notes: Vec<Note> = Vec::new();
|
||||
// Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are
|
||||
// written on a page of their own bounds, which it doesn't grow.
|
||||
let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none();
|
||||
let mut write = |doc: &Document, format: &str, options: &Value| -> Result<Vec<u8>> {
|
||||
// The steps are the files: an encoder's own steps (artboards, pages) are not reported.
|
||||
let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?;
|
||||
for w in notes {
|
||||
if !(bleed_on_page && w == super::pdf::BLEED_LEFT_OUT) && !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
let (bytes, given) = vectorcraft_doc::cancel::quiet(|| encode_with_notes(doc, format, options))?;
|
||||
for n in given.into_iter().filter(|n| !(bleed_on_page && n.text == super::pdf::BLEED_LEFT_OUT)) {
|
||||
add_note(&mut notes, n);
|
||||
}
|
||||
Ok(bytes)
|
||||
};
|
||||
@@ -1226,6 +1225,8 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
|
||||
if o.zip || folder.is_none() {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
}
|
||||
// What the files lose first, across every file.
|
||||
let warnings = note_texts(¬es);
|
||||
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
|
||||
if o.zip {
|
||||
let zip = super::zip::deflate(&out.kept, super::zip::FIXED_TIME, vectorcraft_doc::cancel::is_cancelled);
|
||||
|
||||
@@ -204,6 +204,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
enc.add_setting_notes(out.setting_notes);
|
||||
enc.files.push((artboard, if compressed { vectorcraft_svg::compress_bytes(&bytes) } else { bytes }));
|
||||
}
|
||||
// Live type names its fonts; outlines and embedded fonts are the fallback font's.
|
||||
|
||||
@@ -194,6 +194,21 @@ fn placed_images_and_fill_hatches_can_be_left_out() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The reply gives what the drawings lose before a note on the options, across the files: the
|
||||
/// first artboard's drawing notes the fill hatches that `includeHatches: false` leaves out, and
|
||||
/// the second's also has a see-through rectangle that DXF 2000 writes opaque.
|
||||
#[test]
|
||||
fn what_the_drawings_lose_comes_before_what_an_option_left_out() {
|
||||
let mut s = two_boards();
|
||||
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "dxf", "version": "2000", "includeHatches": false, "useArtboards": true})).unwrap();
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
let Some((option, losses)) = warnings.split_last() else { panic!("no notes") };
|
||||
assert!(option.starts_with("fill hatches are left out"), "{warnings:?}");
|
||||
assert!(losses.iter().any(|w| w.starts_with("transparency needs DXF 2004")), "{warnings:?}");
|
||||
assert!(!losses.iter().any(|w| w.starts_with("fill hatches")), "each note once: {warnings:?}");
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no
|
||||
/// linked file, and `warnings` holds the note raster exports give, once, without naming either
|
||||
/// image's key.
|
||||
|
||||
@@ -117,3 +117,35 @@ fn formats_list_use_artboards() {
|
||||
}
|
||||
assert!(opts("vectorcraft").get("useArtboards").is_none());
|
||||
}
|
||||
|
||||
/// An export's reply gives what the file loses before its notes on the options, whatever order
|
||||
/// the encoder gave them: the WebP encoder notes that lossy WebP isn't applied before the export
|
||||
/// notes the type it drew in the fallback font. With only notes on the options, they keep their
|
||||
/// order.
|
||||
#[test]
|
||||
fn what_the_file_loses_comes_before_a_note_on_the_options() {
|
||||
let lossy = "lossy WebP isn't available yet: the file is lossless (exact, but larger)";
|
||||
let warnings = |s: &mut Session, p: Value| -> Vec<String> {
|
||||
let r = s.execute("document.export", &p).unwrap();
|
||||
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect()
|
||||
};
|
||||
let mut s = session(60.0, 40.0, 1);
|
||||
assert_eq!(warnings(&mut s, json!({"format": "webp", "lossless": false})), [lossy]);
|
||||
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
let w = warnings(&mut s, json!({"format": "webp", "lossless": false}));
|
||||
assert_eq!(w.len(), 2, "{w:?}");
|
||||
assert!(w[0].contains("fallback font") && w[0].contains("Nonexistent Sans Probe"), "{w:?}");
|
||||
assert_eq!(w[1], lossy);
|
||||
// document.serialize gives them in the same order.
|
||||
let r = s.execute("document.serialize", &json!({"format": "webp", "lossless": false})).unwrap();
|
||||
assert_eq!(r["warnings"], json!(w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_texts_give_the_losses_first_each_kind_in_order() {
|
||||
use super::encode::{Note, note_texts};
|
||||
let notes = [Note::setting("s1"), Note::loss("l1"), Note::setting("s2"), Note::loss("l2")];
|
||||
assert_eq!(note_texts(¬es), ["l1", "l2", "s1", "s2"]);
|
||||
assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]);
|
||||
assert!(note_texts(&[]).is_empty());
|
||||
}
|
||||
|
||||
@@ -56,3 +56,21 @@ fn permissions_without_a_password_warn_and_a_password_applies_them() {
|
||||
let bytes = vectorcraft_format::base64_decode(locked["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt"));
|
||||
}
|
||||
|
||||
/// The writer notes the permissions it didn't apply before the export notes the type it drew in
|
||||
/// the fallback font: the reply gives what the file loses first, through both commands.
|
||||
#[test]
|
||||
fn the_fallback_font_note_comes_before_the_permissions_note() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
for r in [
|
||||
s.execute("document.exportPdf", &json!({"security": {"printing": "none"}})).unwrap(),
|
||||
s.execute("document.export", &json!({"format": "pdf", "security": {"printing": "none"}})).unwrap(),
|
||||
] {
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
let at = |s: &str| warnings.iter().position(|w| w.contains(s));
|
||||
let (font, permissions) = (at("Nonexistent Sans Probe"), at("permissions need a password"));
|
||||
assert!(font.is_some() && permissions.is_some() && font < permissions, "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +507,22 @@ fn type_in_a_missing_family_is_named_once_for_every_format() {
|
||||
assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}");
|
||||
}
|
||||
|
||||
/// What a later file loses comes before a note on the options that an earlier file gave: the
|
||||
/// first artboard's WebP gives only the lossy WebP note, the second's also says its type doesn't
|
||||
/// fit.
|
||||
#[test]
|
||||
fn what_any_file_loses_comes_before_the_notes_on_the_options() {
|
||||
let mut s = session(2);
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect;
|
||||
let area = json!({"width": 30, "height": 8});
|
||||
s.execute("text.create", &json!({"x": second.x0 + 2.0, "y": second.y0 + 2.0, "size": 12, "area": area, "text": "Ab\nCd\nEf\nGh"})).unwrap();
|
||||
let r = export(&mut s, json!({"formats": [{"format": "webp", "lossless": false}]}));
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings[0].contains("doesn't fit its frame"), "{warnings:?}");
|
||||
assert!(warnings[1].starts_with("lossy WebP"), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_document_gives_one_file_per_format() {
|
||||
let mut s = session(3);
|
||||
|
||||
@@ -1200,7 +1200,8 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) {
|
||||
/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without
|
||||
/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not
|
||||
/// allow names its file alone, without its relative path or the size and date kept of its file,
|
||||
/// and each data set's image value it does not allow names its file alone too. The folder Export
|
||||
/// and each data set's image value and linked JavaScript file (`svg_scripts`) it does not allow
|
||||
/// names its file alone too, while a JavaScript file given as a URL stays as it is. The folder Export
|
||||
/// for Screens last wrote to is left out of the export settings when it is outside the read roots.
|
||||
/// A link whose path is allowed but whose relative path leads outside the read roots loses its
|
||||
/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited
|
||||
@@ -1266,6 +1267,10 @@ pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Co
|
||||
}
|
||||
}
|
||||
}
|
||||
let script_hidden = |f: &str| !path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url));
|
||||
for f in d.svg_scripts.iter_mut().filter(|f| script_hidden(f)) {
|
||||
*f = LinkInfo::new(f.as_str()).name().to_string();
|
||||
}
|
||||
Cow::Owned(d)
|
||||
}
|
||||
|
||||
|
||||
@@ -133,16 +133,16 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor
|
||||
// the objects differ, the members of groups and the objects in symbols' art included, and with
|
||||
// strokes that scale, when the sizes are out of proportion. The objects are then measured one
|
||||
// by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects,
|
||||
// objects with a butt-capped stroke along a segment at a slant, whose margins change with the
|
||||
// segment's direction, and type measured by its glyphs, are measured as drawn after each scale
|
||||
// tried instead ([`refit_drawn`]).
|
||||
// objects with a stroke that stops at the corners of a segment at a slant (butt caps, bevel and
|
||||
// miter joins), whose margins change with the segment's direction, and type measured by its
|
||||
// glyphs, are measured as drawn after each scale tried instead ([`refit_drawn`]).
|
||||
let to_local = frame.to_doc().inverse();
|
||||
let drawn = || {
|
||||
by_glyphs
|
||||
|| ids
|
||||
.iter()
|
||||
.filter_map(|id| d.node(*id))
|
||||
.any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local))
|
||||
.any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_stroke_corners(&symbols, to_local))
|
||||
};
|
||||
// Without Use Preview Bounds the box has no margins (`v`, `vs` and `g` are one box, glyph ink
|
||||
// included), so the scales above are exact and nothing is refitted.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -645,8 +645,8 @@ pub fn export_pdf(doc: &Document, opts: &vectorcraft_pdf::PdfOptions) -> std::re
|
||||
export_pdf_with_report(doc, opts).map(|r| r.bytes)
|
||||
}
|
||||
|
||||
/// Like [`export_pdf`], also returning the writer's warnings (options not applied yet, features
|
||||
/// approximated or left out).
|
||||
/// Like [`export_pdf`], also returning the writer's warnings (features approximated or left out)
|
||||
/// and its notes on the options (options not applied yet).
|
||||
pub fn export_pdf_with_report(
|
||||
doc: &Document,
|
||||
opts: &vectorcraft_pdf::PdfOptions,
|
||||
|
||||
@@ -1679,3 +1679,164 @@ fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() {
|
||||
let cached = t.cached_bounds.unwrap();
|
||||
assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}");
|
||||
}
|
||||
|
||||
/// Twelve paragraphs that wrap, threaded through three frames, the first two of which end inside
|
||||
/// a paragraph; the last frame's paragraphs are indented 10 pt. → the session and the frames.
|
||||
fn split_story() -> (Session, [u64; 3]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap();
|
||||
let words = "aaa bbb ccc ddd eee fff ggg hhh iii jjj ".repeat(3);
|
||||
let story = (1..=12).map(|i| format!("p{i} {}", words.trim_end())).collect::<Vec<_>>().join("\n");
|
||||
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 50}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = s.execute("text.thread.add", &json!({"from": b, "rect": [600, 10, 250, 950]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [c], "leftIndent": 10})).unwrap();
|
||||
for id in [b, c] {
|
||||
assert!(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap().first_para_continues, "frame {id} starts inside a paragraph");
|
||||
}
|
||||
(s, [a, b, c])
|
||||
}
|
||||
|
||||
/// Each frame's text, paragraph styles and whether it continues a paragraph.
|
||||
fn frames_of(s: &Session, frames: &[u64]) -> Vec<(String, Vec<vectorcraft_doc::ParaStyle>, bool)> {
|
||||
frames
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap();
|
||||
(t.plain_text(), t.paragraph_styles(), t.first_para_continues)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One entry for each paragraph of the story: a paragraph split between frames by its piece in the
|
||||
/// later frame. Each takes indents of its own, wide enough to move text between the frames.
|
||||
fn indent_entries(s: &Session, frames: &[u64]) -> Vec<Value> {
|
||||
let mut out = vec![];
|
||||
for (k, id) in frames.iter().enumerate() {
|
||||
let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap();
|
||||
let next_continues = frames.get(k + 1).is_some_and(|n| text_of(&s.doc().unwrap().doc, NodeId(*n)).unwrap().first_para_continues);
|
||||
let plain = t.plain_text();
|
||||
let pieces: Vec<&str> = plain.split('\n').collect();
|
||||
let mut start = 0;
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
if i + 1 < pieces.len() || !next_continues {
|
||||
let n = out.len() as f64;
|
||||
out.push(json!({"id": id, "start": start, "leftIndent": 20.0 + n, "firstLineIndent": 15.0 - n}));
|
||||
}
|
||||
start += piece.len() + 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `paragraphIndents` sets each paragraph of a threaded story as one `text.setFormat` command for
|
||||
/// each paragraph would, the story's last paragraph first, with a paragraph split between frames
|
||||
/// set once; the story flows again once, and Undo and Redo take the whole change as one step.
|
||||
#[test]
|
||||
fn paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would() {
|
||||
let (mut one, frames) = split_story();
|
||||
let (mut each, _) = split_story();
|
||||
let entries = indent_entries(&one, &frames);
|
||||
assert_eq!(entries.len(), 12, "one entry for each paragraph of the story: {entries:?}");
|
||||
let commands: Vec<Value> = entries
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|e| {
|
||||
json!({"command": "text.setFormat", "params": {
|
||||
"ids": [e["id"]], "start": e["start"], "end": e["start"], "leftIndent": e["leftIndent"], "firstLineIndent": e["firstLineIndent"]
|
||||
}})
|
||||
})
|
||||
.collect();
|
||||
each.execute("command.batch", &json!({"label": "Left Indent", "commands": commands})).unwrap();
|
||||
let start = frames_of(&one, &frames);
|
||||
let undo = one.doc().unwrap().history.undo.len();
|
||||
let copied = COPIED.with(std::cell::Cell::get);
|
||||
let r = one.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap();
|
||||
assert_eq!(COPIED.with(std::cell::Cell::get), copied + 1, "the story flows again once");
|
||||
// The first frame holds only the start of the paragraph that runs on into the second.
|
||||
assert_eq!(r, json!({"ids": frames[1..]}));
|
||||
assert_eq!(one.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let set = frames_of(&one, &frames);
|
||||
assert_ne!(set, start);
|
||||
assert_eq!(set, frames_of(&each, &frames));
|
||||
one.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frames_of(&one, &frames), start);
|
||||
one.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(frames_of(&one, &frames), set);
|
||||
}
|
||||
|
||||
/// Thousands of paragraphs of a threaded story are set by one edit, which flows the story again
|
||||
/// once.
|
||||
#[test]
|
||||
fn paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap();
|
||||
let story = vec!["x"; 5000].join("\n");
|
||||
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 300}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 300]})).unwrap()["id"].as_u64().unwrap();
|
||||
let entries: Vec<Value> = [a, b]
|
||||
.into_iter()
|
||||
.flat_map(|id| {
|
||||
let t = text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap();
|
||||
let count = t.paragraph_count();
|
||||
(0..count).map(move |i| json!({"id": id, "start": 2 * i, "leftIndent": (i % 7) as f64}))
|
||||
})
|
||||
.collect();
|
||||
assert!(entries.len() >= 5000, "{}", entries.len());
|
||||
let (undo, copied) = (s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get));
|
||||
s.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo + 1, copied + 1));
|
||||
let t = text_of(&s.doc().unwrap().doc, NodeId(a)).unwrap();
|
||||
assert_eq!((t.para_at(0).left_indent, t.para_at(1).left_indent, t.para_at(8).left_indent), (0.0, 1.0, 1.0));
|
||||
}
|
||||
|
||||
/// `paragraphIndents` that would change no paragraph leaves the document and its history alone, and
|
||||
/// a bad one is refused before anything changes.
|
||||
#[test]
|
||||
fn paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing() {
|
||||
let (mut s, [a, b, c]) = split_story();
|
||||
let start = frames_of(&s, &[a, b, c]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let copied = COPIED.with(std::cell::Cell::get);
|
||||
let r =
|
||||
s.execute("text.setFormat", &json!({"paragraphIndents": [{"id": a, "start": 0, "leftIndent": 0}, {"id": c, "start": 0, "leftIndent": 10}]}));
|
||||
assert_eq!(r.unwrap(), json!({"ids": [a, c]}));
|
||||
assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo, copied), "nothing to change");
|
||||
let paragraph = text(&s, c).find('\n').unwrap() + 1;
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 900, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let ok = json!({"id": c, "start": paragraph, "leftIndent": 30});
|
||||
for bad in [
|
||||
json!({"paragraphIndents": []}),
|
||||
json!({"paragraphIndents": {"id": a, "start": 0, "leftIndent": 30}}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "ids": [a]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "leftIndent": 5}),
|
||||
json!({"paragraphIndents": [ok, 3]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": "30"}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 1300}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "firstLineIndent": -1297}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30, "spaceBefore": 4}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": "a", "start": 0, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": rect, "start": 0, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": 999_999, "start": 0, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": -1, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 1, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 31}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 30, "firstLineIndent": 0}]}),
|
||||
json!({"paragraphIndents": vec![ok.clone(); crate::cmd::panelcmds::MAX_PARAGRAPH_INDENTS + 1]}),
|
||||
] {
|
||||
assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}");
|
||||
assert_eq!(frames_of(&s, &[a, b, c]), start, "{bad}");
|
||||
}
|
||||
// The same entry twice is one change.
|
||||
s.execute("text.setFormat", &json!({"paragraphIndents": [ok, ok]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(text_of(&s.doc().unwrap().doc, NodeId(c)).unwrap().para_at(1).left_indent, 30.0);
|
||||
}
|
||||
|
||||
@@ -2209,3 +2209,127 @@ fn set_path_handle_reshapes_type_on_a_path() {
|
||||
assert!(s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 9, "which": "out", "x": 0, "y": 0})).is_err(), "no such anchor");
|
||||
assert!(s.execute("text.setPathHandle", &json!({"id": path["id"], "anchor": 0, "which": "out", "x": 0, "y": 0})).is_err(), "not type");
|
||||
}
|
||||
|
||||
/// `text.setFormat` refuses a number or switch given as another type, naming the key, and
|
||||
/// changes nothing; null leaves the attribute as it is.
|
||||
#[test]
|
||||
fn set_format_refuses_a_param_of_the_wrong_type() {
|
||||
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": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let before = state(&s);
|
||||
for key in [
|
||||
"baselineShift",
|
||||
"hScale",
|
||||
"vScale",
|
||||
"rotation",
|
||||
"leftIndent",
|
||||
"rightIndent",
|
||||
"firstLineIndent",
|
||||
"spaceBefore",
|
||||
"spaceAfter",
|
||||
"wordsLongerThan",
|
||||
"afterFirst",
|
||||
"beforeLast",
|
||||
"hyphenLimit",
|
||||
"hyphenationZone",
|
||||
] {
|
||||
for wrong in [json!("30"), json!(true), json!([30])] {
|
||||
let err = s.execute("text.setFormat", &json!({ key: wrong })).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("`{key}` must be a number, not {wrong}")), "{key}: {err}");
|
||||
}
|
||||
}
|
||||
for key in ["underline", "allCaps", "hyphenate", "noBreak"] {
|
||||
let err = s.execute("text.setFormat", &json!({ key: "true" })).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("`{key}` must be true or false")), "{key}: {err}");
|
||||
}
|
||||
let err = s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": "12"})).unwrap_err().to_string();
|
||||
assert!(err.contains("`firstLineIndent` must be a number"), "{err}");
|
||||
assert!(state(&s) == before, "a refused call changes nothing and records no undo step");
|
||||
s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": null})).unwrap();
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (30.0, 0.0));
|
||||
}
|
||||
|
||||
/// `text.setFormat` reads a null value as one left out: a call whose attributes are all null has
|
||||
/// nothing to change, and records no edit, no undo step and no new revision.
|
||||
#[test]
|
||||
fn set_format_with_only_null_values_changes_nothing() {
|
||||
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": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.clone(), st.history.undo.len(), st.revision)
|
||||
};
|
||||
let before = state(&s);
|
||||
for params in [
|
||||
json!({"leftIndent": null}),
|
||||
json!({"leftIndent": null, "firstLineIndent": null, "underline": null}),
|
||||
json!({"kerning": null, "antiAlias": null, "list": null, "wordSpacing": null}),
|
||||
json!({"mojikumi": null}),
|
||||
json!({"kinsoku": null, "kinsokuType": null, "direction": null, "leadingModel": null}),
|
||||
json!({"burasagari": null, "kashida": null, "singleWordJustification": null, "autoLeading": null}),
|
||||
json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": null}),
|
||||
] {
|
||||
let err = s.execute("text.setFormat", ¶ms).unwrap_err().to_string();
|
||||
assert!(err.contains("nothing to change"), "{params}: {err}");
|
||||
assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision");
|
||||
}
|
||||
// A null beside a value leaves only its own attribute.
|
||||
let para = |d: &vectorcraft_doc::Document| {
|
||||
let NodeKind::Text(t) = &d.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
(t.para_at(0).mojikumi, t.para_at(0).kinsoku)
|
||||
};
|
||||
let mojikumi = para(&before.0).0;
|
||||
s.execute("text.setFormat", &json!({"mojikumi": null, "kinsoku": "soft"})).unwrap();
|
||||
assert_eq!(para(&s.doc().unwrap().doc), (mojikumi, vectorcraft_doc::Kinsoku::Soft));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 1, "one undo step");
|
||||
}
|
||||
|
||||
/// `text.setFormat` with the values the text already has succeeds and names its objects, but
|
||||
/// records no edit, no undo step and no new revision.
|
||||
#[test]
|
||||
fn set_format_with_the_current_values_records_nothing() {
|
||||
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": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.clone(), st.history.undo.len(), st.revision)
|
||||
};
|
||||
let before = state(&s);
|
||||
let NodeKind::Text(t) = &before.0.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
let hyphenate = t.para_at(0).hyphenate;
|
||||
for params in [
|
||||
json!({"leftIndent": 0, "firstLineIndent": 0}),
|
||||
json!({"underline": false, "strikethrough": false, "hyphenate": hyphenate}),
|
||||
json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": 0, "spaceAfter": 0}),
|
||||
json!({"ids": [id], "start": 4, "end": 7, "leftIndent": null, "rightIndent": 0}),
|
||||
] {
|
||||
let out = s.execute("text.setFormat", ¶ms).unwrap();
|
||||
assert_eq!(out["ids"], json!([id]), "{params}");
|
||||
assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision");
|
||||
}
|
||||
// A call that changes a value is one undo step; the same call again records nothing.
|
||||
s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap();
|
||||
let changed = state(&s);
|
||||
assert_eq!((changed.1, changed.2), (before.1 + 1, before.2 + 1), "one undo step and one revision");
|
||||
s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap();
|
||||
assert!(state(&s) == changed, "the same value again records nothing");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc == before.0, "one undo restores the text");
|
||||
// The anti-aliasing method is compared the same way.
|
||||
s.execute("text.setFormat", &json!({"antiAlias": "none"})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap();
|
||||
let strong = state(&s);
|
||||
assert!(strong.1 > before.1, "a new method is an undo step");
|
||||
let NodeKind::Text(t) = &strong.0.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.anti_alias, vectorcraft_doc::text::TypeAntiAlias::Strong);
|
||||
s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap();
|
||||
assert!(state(&s) == strong, "the same method again records nothing");
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ fn selection_and_unlocked(s: &Session) -> std::result::Result<(), String> {
|
||||
variables_unlocked(s)
|
||||
}
|
||||
|
||||
/// Ends a typing session as [`Session::edit`] would, before a helper below compares the document,
|
||||
/// Ends a typing session as [`Session::edit`] would, before a command compares the document,
|
||||
/// so the typing is its own undo step whether or not the command then changes anything.
|
||||
fn settle_typing(s: &mut Session) -> Result<()> {
|
||||
pub(super) fn settle_typing(s: &mut Session) -> Result<()> {
|
||||
if !s.previewing && super::typing_in_progress(s) {
|
||||
super::settle_interaction(s)?;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Window › 3D and Materials › Quick Actions › Expand as Wireframes: each object with a 3D effect
|
||||
//! becomes a group of stroked paths, the edges of the faces its effect draws.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::AppearanceItem;
|
||||
use vectorcraft_doc::{Appearance, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::{CommandSpec, Result, Session, bad, checked_ids_or_selection, cmd, has_doc};
|
||||
|
||||
const C: &str = "effect.expandAsWireframes";
|
||||
|
||||
/// The name of the undo step.
|
||||
const LABEL: &str = "Expand as Wireframes";
|
||||
|
||||
/// The weight of a wireframe edge's stroke, in points (VectorCraft's own).
|
||||
pub const WIRE_WEIGHT: f64 = 0.5;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"effect.expandAsWireframes",
|
||||
"Expand as Wireframes",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides and paths made into guides; a path inside a compound path in ids stands for the compound path); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}",
|
||||
has_doc,
|
||||
expand_as_wireframes
|
||||
)]
|
||||
}
|
||||
|
||||
/// A wireframe edge's appearance: a 0.5 pt black stroke with round joins and no fill.
|
||||
fn wire_appearance() -> Appearance {
|
||||
let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), WIRE_WEIGHT);
|
||||
stroke.join = LineJoin::Round;
|
||||
Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() }
|
||||
}
|
||||
|
||||
/// The guides (ruler guides and paths made into guides) of layer `n` and then those of the groups
|
||||
/// and sublayers inside it, in order, added to `out`. A guide from a hidden or locked sublayer, or
|
||||
/// from one inside a hidden or locked group or sublayer, is hidden or locked itself.
|
||||
fn guides(n: &Node, hidden: bool, locked: bool, out: &mut Vec<Arc<Node>>) {
|
||||
let Some(children) = n.children() else { return };
|
||||
for c in children.iter().filter(|c| c.is_guide()) {
|
||||
let mut g = c.clone();
|
||||
if hidden || locked {
|
||||
let g = Arc::make_mut(&mut g);
|
||||
g.visible &= !hidden;
|
||||
g.locked |= locked;
|
||||
}
|
||||
out.push(g);
|
||||
}
|
||||
for c in children.iter().filter(|c| !c.is_guide()) {
|
||||
guides(c, hidden || !c.visible, locked || c.locked, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "select an object with a 3D effect, or give its id in ids"));
|
||||
}
|
||||
// Every wireframe is built before the edit, so a failure leaves the document and its history as
|
||||
// they are.
|
||||
let d = &s.doc()?.doc;
|
||||
let mut wires = vec![];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for id in roots {
|
||||
// A path inside a compound path stands for the compound path, which draws it. An id given
|
||||
// twice is expanded once.
|
||||
let id = super::path::compound_or_self(d, id);
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = d.node(id) else { continue };
|
||||
if !effects::has_revolve(n) && !effects::has_solid(n) {
|
||||
continue;
|
||||
}
|
||||
let wire = effects::three_d_wireframe_in(Some(d), n).map_err(|e| bad(C, format!("object {}: {e}", id.0)))?;
|
||||
wires.push((id, wire));
|
||||
}
|
||||
if wires.is_empty() {
|
||||
return Err(bad(C, "no selected or given object has a visible 3D effect"));
|
||||
}
|
||||
let ids: Vec<NodeId> = s.edit(LABEL, |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut selected = vec![];
|
||||
for (id, wire) in wires {
|
||||
let Some(source) = d.node(id) else { continue };
|
||||
if matches!(source.kind, NodeKind::Layer { .. }) {
|
||||
// A layer stays a layer: its members become the edges, and its 3D effect is removed.
|
||||
// Its guides and those of its sublayers are not art: they stay, after the edges.
|
||||
let mut layer = source.clone();
|
||||
let mut kept = vec![];
|
||||
guides(source, false, false, &mut kept);
|
||||
let mut children: Vec<Arc<Node>> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect();
|
||||
selected.extend(children.iter().map(|c| c.id));
|
||||
children.extend(kept);
|
||||
if let Some(members) = layer.children_mut() {
|
||||
*members = children;
|
||||
}
|
||||
layer.appearance = Default::default();
|
||||
if let Some(slot) = d.node_mut(id) {
|
||||
*slot = layer;
|
||||
out.push(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The group keeps the object's name, visibility, lock, bounding box angle, transparency
|
||||
// (opacity, blending mode, isolation, knockout and opacity mask), attributes, slice and text
|
||||
// wrap.
|
||||
let mut group = Node::group(id, vec![]);
|
||||
group.name = source.name.clone();
|
||||
group.visible = source.visible;
|
||||
group.locked = source.locked;
|
||||
group.opacity = source.opacity;
|
||||
group.blend = source.blend;
|
||||
group.isolate = source.isolate;
|
||||
group.knockout = source.knockout;
|
||||
group.knockout_shape = source.knockout_shape;
|
||||
group.bbox_angle = source.bbox_angle;
|
||||
let extras = source.extras();
|
||||
let (mask, attrs, slice, wrap) = (extras.mask.clone(), extras.attrs.clone(), extras.slice.clone(), extras.wrap);
|
||||
if mask.is_some() || attrs.is_some() || slice.is_some() || wrap.is_some() {
|
||||
let mut e = group.extras_mut();
|
||||
e.mask = mask;
|
||||
e.attrs = attrs;
|
||||
e.slice = slice;
|
||||
e.wrap = wrap;
|
||||
}
|
||||
let children = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect();
|
||||
group.kind = NodeKind::Group { children, clip: false };
|
||||
if let Some(slot) = d.node_mut(id) {
|
||||
*slot = group;
|
||||
out.push(id);
|
||||
selected.push(id);
|
||||
}
|
||||
}
|
||||
sel.set(selected);
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,240 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::appearance::AppearanceItem;
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A new document with a filled 100 pt square; `effect` is applied to it when given.
|
||||
fn setup(effect: Option<&str>) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap();
|
||||
if let Some(effect) = effect {
|
||||
s.execute("effect.apply", &json!({"effect":effect,"item":null,"params":{}})).unwrap();
|
||||
}
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: Value) -> crate::Result<Value> {
|
||||
s.execute("effect.expandAsWireframes", &p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3d_object_becomes_a_selected_group_of_stroked_edges_in_one_undo_step() {
|
||||
for effect in [
|
||||
"threeD.extrude",
|
||||
"threeD.classicExtrude",
|
||||
"threeD.revolve",
|
||||
"threeD.classicRevolve",
|
||||
"threeD.inflate",
|
||||
"threeD.rotate",
|
||||
"threeD.classicRotate",
|
||||
] {
|
||||
let (mut s, id) = setup(Some(effect));
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().name = Some("Box".into());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = undo_len(&s);
|
||||
let out = run(&mut s, json!({})).unwrap_or_else(|e| panic!("{effect}: {e}"));
|
||||
assert_eq!(out["ids"], json!([id.0]), "{effect}");
|
||||
let st = s.doc().unwrap();
|
||||
let group = st.doc.node(id).unwrap();
|
||||
let NodeKind::Group { children, clip: false } = &group.kind else { panic!("{effect}: a group") };
|
||||
assert!(!children.is_empty(), "{effect}");
|
||||
assert_eq!(group.name.as_deref(), Some("Box"), "{effect}");
|
||||
assert!(group.appearance.effects.is_empty(), "{effect}: the effect is gone");
|
||||
for c in children {
|
||||
assert!(c.path_data().is_some(), "{effect}: paths only");
|
||||
let [AppearanceItem::Stroke(stroke)] = c.appearance.items.as_slice() else { panic!("{effect}: one stroke and no fill") };
|
||||
assert_eq!(stroke.width, super::WIRE_WEIGHT);
|
||||
}
|
||||
assert_eq!(st.selection.objects, vec![id], "{effect}");
|
||||
assert_eq!(undo_len(&s), history + 1, "{effect}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{effect}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_group_keeps_the_object_s_transparency() {
|
||||
let (mut s, id) = setup(Some("threeD.extrude"));
|
||||
s.execute("transparency.set", &json!({"opacity": 50, "knockout": "on"})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().bbox_angle = 0.25;
|
||||
let before = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
run(&mut s, json!({})).unwrap();
|
||||
let group = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!((group.opacity - 0.5).abs() < 1e-6, "{}", group.opacity);
|
||||
assert_eq!((group.opacity, group.blend, group.knockout), (before.opacity, before.blend, before.knockout));
|
||||
assert_eq!(group.bbox_angle, 0.25, "the bounding box keeps its angle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_with_a_3d_effect_stays_a_layer_holding_the_edges() {
|
||||
let (mut s, _) = setup(None);
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap();
|
||||
let history = undo_len(&s);
|
||||
// An id given twice is expanded once.
|
||||
let out = run(&mut s, json!({"ids":[layer.0, layer.0]})).unwrap();
|
||||
assert_eq!(out["ids"], json!([layer.0]));
|
||||
let st = s.doc().unwrap();
|
||||
let node = st.doc.node(layer).unwrap();
|
||||
assert!(matches!(node.kind, NodeKind::Layer { .. }));
|
||||
assert!(node.appearance.effects.is_empty());
|
||||
let children = node.children().unwrap();
|
||||
assert!(!children.is_empty() && children.iter().all(|c| c.path_data().is_some()));
|
||||
assert_eq!(st.selection.objects, children.iter().map(|c| c.id).collect::<Vec<_>>());
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
}
|
||||
|
||||
/// A layer's ruler guides are not art: they stay on the layer, after the edges, and are not selected.
|
||||
#[test]
|
||||
fn a_layer_keeps_its_ruler_guides() {
|
||||
let (mut s, _) = setup(None);
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap();
|
||||
let guides = |s: &Session| -> Vec<NodeId> {
|
||||
s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().filter(|c| c.is_ruler_guide()).map(|c| c.id).collect()
|
||||
};
|
||||
let before = guides(&s);
|
||||
assert_eq!(before.len(), 1);
|
||||
s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap();
|
||||
run(&mut s, json!({"ids":[layer.0]})).unwrap();
|
||||
assert_eq!(guides(&s), before);
|
||||
let st = s.doc().unwrap();
|
||||
let children = st.doc.node(layer).unwrap().children().unwrap();
|
||||
assert!(children.len() > 1 && children.last().is_some_and(|c| c.is_ruler_guide()));
|
||||
assert!(!st.selection.objects.is_empty() && st.selection.objects.iter().all(|id| !before.contains(id)));
|
||||
}
|
||||
|
||||
/// A path made into a guide is not art either: it stays on the layer, after the edges, and is not
|
||||
/// selected.
|
||||
#[test]
|
||||
fn a_layer_keeps_its_path_guides() {
|
||||
let (mut s, id) = setup(None);
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
s.execute("path.create", &json!({"d":"M0 300L300 300"})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
let guides = |s: &Session| -> Vec<NodeId> {
|
||||
s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().filter(|c| c.is_guide()).map(|c| c.id).collect()
|
||||
};
|
||||
let before = guides(&s);
|
||||
assert_eq!(before.len(), 1);
|
||||
assert_ne!(before[0], id);
|
||||
s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap();
|
||||
run(&mut s, json!({"ids":[layer.0]})).unwrap();
|
||||
assert_eq!(guides(&s), before);
|
||||
let st = s.doc().unwrap();
|
||||
let children = st.doc.node(layer).unwrap().children().unwrap();
|
||||
assert!(children.len() > 1 && children.last().is_some_and(|c| c.is_guide()));
|
||||
assert!(!st.selection.objects.is_empty() && st.selection.objects.iter().all(|i| !before.contains(i)));
|
||||
}
|
||||
|
||||
/// The number of segments in the wireframe group `id`.
|
||||
fn segments(s: &Session, id: NodeId) -> usize {
|
||||
let st = s.doc().unwrap();
|
||||
st.doc.node(id).unwrap().children().unwrap().iter().flat_map(|c| &c.path_data().unwrap().subpaths).map(|sp| sp.segment_count()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_expands_to_the_nine_edges_of_its_three_faces() {
|
||||
let (mut s, id) = setup(Some("threeD.extrude"));
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
run(&mut s, json!({"ids":[id.0]})).unwrap();
|
||||
assert_eq!(segments(&s, id), 9);
|
||||
// A stroke is outlined before the effect, so its own solid adds its edges.
|
||||
let (mut s, id) = setup(Some("threeD.extrude"));
|
||||
run(&mut s, json!({"ids":[id.0]})).unwrap();
|
||||
assert!(segments(&s, id) > 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_without_a_3d_effect_are_left_and_none_at_all_is_an_error() {
|
||||
let (mut s, plain) = setup(None);
|
||||
let history = undo_len(&s);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let e = run(&mut s, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("no selected or given object has a visible 3D effect"), "{e}");
|
||||
assert_eq!(undo_len(&s), history, "no undo step when nothing changes");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
|
||||
// A hidden 3D effect draws nothing to expand.
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.extrude","item":null,"params":{}})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"visible":false})).unwrap();
|
||||
assert!(run(&mut s, json!({})).is_err());
|
||||
|
||||
// With nothing selected.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let e = run(&mut s, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("select an object with a 3D effect"), "{e}");
|
||||
|
||||
// A second object with a 3D effect expands; the plain one is left as it is.
|
||||
let other = NodeId(s.execute("path.create", &json!({"d":"M250 250L300 250L300 300Z"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("effect.remove", &json!({"ids":[plain.0],"index":0,"item":null})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids":[other.0],"color":"#ff0000"})).unwrap();
|
||||
s.execute("effect.apply", &json!({"ids":[other.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap();
|
||||
let kept = s.doc().unwrap().doc.node(plain).unwrap().clone();
|
||||
let out = run(&mut s, json!({"ids":[plain.0, other.0]})).unwrap();
|
||||
assert_eq!(out["ids"], json!([other.0]));
|
||||
assert_eq!(s.doc().unwrap().doc.node(plain).unwrap(), &kept);
|
||||
}
|
||||
|
||||
/// A live-effect plug-in after a fill's 3D effect that links its pieces to one of the document's
|
||||
/// swatches reshapes the edges, as it reshapes the drawn surface.
|
||||
#[test]
|
||||
fn a_painted_effect_after_a_fill_s_3d_effect_finds_the_document_s_swatches() {
|
||||
use vectorcraft_geom::{PathData, Point, SubPath};
|
||||
let mut leaf = serde_json::to_value(vectorcraft_color::Paint::solid(vectorcraft_color::Color::from_hex("#00ff00").unwrap())).unwrap();
|
||||
leaf["swatch"] = json!("Leaf");
|
||||
let corners = [Point::new(300.0, 300.0), Point::new(320.0, 300.0), Point::new(320.0, 320.0), Point::new(300.0, 320.0)];
|
||||
let output = json!({"objects": [{"path": PathData::single(SubPath::polyline(&corners, true)), "fills": [leaf]}]}).to_string();
|
||||
let manifest = r#"{"id":"test.engine.wireleaf","name":"Test wireleaf","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{}}"#;
|
||||
let wasm = wat::parse_str(vectorcraft_plugins::wat::returning(manifest, &output)).unwrap();
|
||||
for effect in ["threeD.extrude", "threeD.revolve"] {
|
||||
let (mut s, id) = setup(None);
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "replace": true})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Leaf", "color": "#00ff00", "global": true})).unwrap();
|
||||
let items = &s.doc().unwrap().doc.node(id).unwrap().appearance.items;
|
||||
let fill = items.iter().position(|i| matches!(i, AppearanceItem::Fill(_))).unwrap();
|
||||
for e in [effect, "plugin.test.engine.wireleaf"] {
|
||||
s.execute("effect.apply", &json!({"ids": [id.0], "effect": e, "item": fill, "params": {}})).unwrap();
|
||||
}
|
||||
run(&mut s, json!({"ids": [id.0]})).unwrap_or_else(|e| panic!("{effect}: {e}"));
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.x1, b.y1), (300.0, 300.0, 320.0, 320.0), "{effect}: the plug-in's square");
|
||||
assert_eq!(segments(&s, id), 4, "{effect}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A path inside a compound path in `ids` stands for the compound path: the compound path becomes
|
||||
/// the group, and no member is replaced by a group inside it.
|
||||
#[test]
|
||||
fn a_compound_path_member_in_ids_expands_the_compound_path() {
|
||||
let (mut s, a) = setup(None);
|
||||
let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids":[a.0, b.0]})).unwrap();
|
||||
let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"ids":[c.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap();
|
||||
let member = s.doc().unwrap().doc.node(c).unwrap().children().unwrap()[0].id;
|
||||
let out = run(&mut s, json!({"ids":[member.0]})).unwrap();
|
||||
assert_eq!(out["ids"], json!([c.0]));
|
||||
let st = s.doc().unwrap();
|
||||
let NodeKind::Group { children, clip: false } = &st.doc.node(c).unwrap().kind else { panic!("the compound path is a group") };
|
||||
assert!(!children.is_empty() && children.iter().all(|ch| ch.path_data().is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_ids_are_errors() {
|
||||
let (mut s, _) = setup(Some("threeD.extrude"));
|
||||
for p in [json!({"ids":[9999]}), json!({"ids":"x"}), json!({"ids":[-1]})] {
|
||||
let history = undo_len(&s);
|
||||
assert!(run(&mut s, p.clone()).is_err(), "{p}");
|
||||
assert_eq!(undo_len(&s), history, "{p}");
|
||||
}
|
||||
}
|
||||
@@ -529,6 +529,42 @@ fn a_spread_gradient_expands_into_whole_bands_for_each_run() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient that repeats across a box in more runs than are written (700 runs of 0.2 pt) is not
|
||||
/// expanded into its first runs and a flat last color: the fill stays a gradient, and the rest of
|
||||
/// the object (its stroke) still expands. `object.expand.info` reports no gradient or fill to
|
||||
/// expand, and Expand with only Fill on reports nothing to expand; the same gradient in 70 runs
|
||||
/// (10 repeats) reports both.
|
||||
#[test]
|
||||
fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() {
|
||||
let gradient = |repeats: u32| {
|
||||
json!({"kind": "linear", "spread": "repeat", "repeats": repeats, "start": [30, 100], "end": [50, 100],
|
||||
"stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]})
|
||||
};
|
||||
for (repeats, expands) in [(10, true), (100, false)] {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(repeats), true);
|
||||
let info = s.execute("object.expand.info", &json!({})).unwrap();
|
||||
assert_eq!(
|
||||
(info["gradient"].clone(), info["fill"].clone(), info["stroke"].clone()),
|
||||
(json!(expands), json!(expands), json!(true)),
|
||||
"{repeats}"
|
||||
);
|
||||
let only_fill = s.execute("object.expand", &json!({"object": false, "stroke": false}));
|
||||
assert_eq!(only_fill.is_ok(), expands, "{repeats}: {only_fill:?}");
|
||||
}
|
||||
for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(100), true);
|
||||
let before = render(&s);
|
||||
run(&mut s, "object.expand", to.clone());
|
||||
let after = render(&s);
|
||||
assert!(mean_diff(&before, &after) < 2.0, "{to}: {}", mean_diff(&before, &after));
|
||||
let mut gradients = 0;
|
||||
s.doc().unwrap().doc.walk(|n| gradients += usize::from(matches!(n.appearance.fill_paint(), Paint::Gradient(_))));
|
||||
assert_eq!(gradients, 1, "{to}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() {
|
||||
for gradient in [
|
||||
|
||||
@@ -1102,6 +1102,13 @@ fn document_replies_leave_out_paths_outside_the_read_roots() {
|
||||
s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap();
|
||||
let image = r["ids"][0].as_u64().unwrap();
|
||||
// A linked JavaScript file outside the read roots, and one given as a URL.
|
||||
let script = away.file("menu.js");
|
||||
write(&script, b"void 0;");
|
||||
let url = "https://example.com/scripts/menu.js";
|
||||
for file in [script.as_str(), url] {
|
||||
s.execute("svgInteractivity.addFile", &json!({"file": file})).unwrap();
|
||||
}
|
||||
let poster = away.file("poster.vectorcraft");
|
||||
save(&mut s, &poster);
|
||||
// The folder Export for Screens wrote to, and a pattern of the linked image being edited: its
|
||||
@@ -1124,11 +1131,13 @@ fn document_replies_leave_out_paths_outside_the_read_roots() {
|
||||
assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}");
|
||||
}
|
||||
assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}");
|
||||
assert!(model.contains(&format!("\"svg_scripts\":[\"menu.js\",\"{url}\"]")), "{model}");
|
||||
// The document keeps its links, and a caller whose read roots hold the files sees every path.
|
||||
assert_eq!(placed(&s, id).link.path, src);
|
||||
assert_eq!(s.doc().unwrap().doc.svg_scripts, [script.as_str(), url]);
|
||||
for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] {
|
||||
let model = model.to_string();
|
||||
for path in [&src, &pic, &out] {
|
||||
for path in [&src, &pic, &out, &script] {
|
||||
assert!(model.contains(&json!(path).to_string()), "{path}: {model}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,6 +722,27 @@ fn expanding_a_gradient_fill_keeps_each_piece_s_own_paint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `object.expand.info` measures each piece a painted effect returns: a piece whose repeating
|
||||
/// gradient needs more runs across it than are written (1,000) reports no gradient to expand, as
|
||||
/// Expand leaves it a gradient, and the same gradient in 10 runs reports one.
|
||||
#[test]
|
||||
fn expand_info_measures_the_gradient_on_each_painted_piece() {
|
||||
for (end, expands) in [(120.0, true), (100.2, false)] {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
let gradient = json!({"kind": "linear", "spread": "repeat", "repeats": 10, "start": [100, 50], "end": [end, 50]});
|
||||
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": gradient}));
|
||||
let out = json!({"objects": [{"path": square_at(100.0)}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&painting("test.engine.expandruns", true), &out));
|
||||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandruns"}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
let info = s.execute("object.expand.info", &json!({})).unwrap();
|
||||
assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(expands), json!(expands)), "{end}");
|
||||
let only_fill = s.execute("object.expand", &json!({"object": false, "fill": true, "stroke": false}));
|
||||
assert_eq!(only_fill.is_ok(), expands, "{end}: {only_fill:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Object › Expand of a pattern fill works piece by piece: a piece that keeps the pattern has its
|
||||
/// tiles expanded, and a piece with a paint of its own keeps that paint.
|
||||
#[test]
|
||||
|
||||
@@ -581,11 +581,13 @@ fn set_bounds_measures_paths_turned_together_square_to_their_box() {
|
||||
fn a_size_below_what_the_kept_strokes_of_several_paths_add_gives_them_no_width() {
|
||||
// With no width, the paths have a visual box 10 pt wide, the width of the thick stroke. A width
|
||||
// of 0 or more below 10 gives both paths no width, and the left edge of the visual box stays at
|
||||
// -2. A negative width turns the paths over, as it does one path.
|
||||
// -2. With no width each path turns back at its top and bottom, where the miter join bevels,
|
||||
// so the visual box runs from y 100 to 150. A negative width turns the paths over, as it does
|
||||
// one path.
|
||||
let mut wrong = vec![];
|
||||
for (width, paths, visual) in [
|
||||
(8.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)),
|
||||
(0.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)),
|
||||
(8.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 100.0, 8.0, 150.0)),
|
||||
(0.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 100.0, 8.0, 150.0)),
|
||||
(-37.0, [Rect::new(-44.0, 100.0, 0.0, 150.0), Rect::new(-44.0, 100.0, -13.2, 150.0)], Rect::new(-49.0, 95.0, 1.0, 155.0)),
|
||||
] {
|
||||
let (mut s, ids) = thin_and_thick();
|
||||
@@ -666,6 +668,35 @@ fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion(
|
||||
assert!((v.width() - 60.0).abs() < 1e-6 && (v.height() - 20.0).abs() < 1e-6, "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_bounds_gives_a_closed_path_with_beveled_or_mitered_corners_the_size_out_of_proportion() {
|
||||
// The bevel at the triangle's tip, the miter the limit bevels there and the miter spikes it
|
||||
// keeps reach past the path by margins that follow the slanted segments' directions, which a
|
||||
// scale out of proportion changes. The miter spikes of a stroke whose dashes have round or
|
||||
// projecting caps reach past the path too.
|
||||
let mut wrong = vec![];
|
||||
for (join, limit, cap, dash) in [
|
||||
("bevel", 10.0, "round", Value::Null),
|
||||
("miter", 2.0, "round", Value::Null),
|
||||
("miter", 10.0, "round", Value::Null),
|
||||
("miter", 10.0, "round", json!([6, 3])),
|
||||
("miter", 10.0, "projecting", json!([6, 3])),
|
||||
] {
|
||||
let mut s = session();
|
||||
let anchors = json!([{"x": 100, "y": 150}, {"x": 110, "y": 100}, {"x": 120, "y": 150}]);
|
||||
let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "stroke.set", json!({"weight": 2, "cap": cap, "join": join, "miterLimit": limit, "dash": dash}));
|
||||
preview(&mut s, true);
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false}));
|
||||
let v = s.transform_bounds(&[id]).unwrap();
|
||||
if (v.width() - 60.0).abs() > 1e-6 || (v.height() - 20.0).abs() > 1e-6 {
|
||||
wrong.push(format!("{join} {limit} {cap} {dash}: {v:?}"));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_bounds_gives_a_symbol_instance_of_a_diagonal_butt_line_the_size_out_of_proportion() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
//! symbols past 4,096 as their art (symbol art past [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects
|
||||
//! in all left out), instances of symbols the document doesn't have as empty groups, symbols' 9-slice
|
||||
//! guides, export types and dynamic type left out, live objects (placed documents included) as their art,
|
||||
//! strokes a plain line can't draw
|
||||
//! strokes a plain line can't draw and the object's art style doesn't hold
|
||||
//! as their filled outlines, several fills or strokes as a group of a path for each, freeform
|
||||
//! 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
|
||||
|
||||
@@ -6758,6 +6758,70 @@ 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:?}");
|
||||
}
|
||||
|
||||
/// A drawn look's strokes warn only where its art style leaves them out: a path with a drop shadow and
|
||||
/// a stroke with a width profile is written with a style that holds the profile, and doesn't warn;
|
||||
/// a group with a drop shadow whose member has a stroke inside the path still warns.
|
||||
#[test]
|
||||
fn a_drawn_looks_strokes_warn_only_where_its_style_leaves_them_out() {
|
||||
let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] };
|
||||
let (n, warnings, p) = read_one(|doc| {
|
||||
let mut stroke = StrokeLayer::new(black_fill(), 4.0);
|
||||
stroke.profile = Some(profile.clone());
|
||||
let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![drop_shadow()], contents_index: None };
|
||||
Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap)
|
||||
});
|
||||
assert!(p.contains("%AI9_BeginArtStyles"), "{p}");
|
||||
assert!(!warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}");
|
||||
assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile));
|
||||
let (_, warnings, p) = read_one(|doc| {
|
||||
let mut n =
|
||||
Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), Appearance::basic(Paint::None, black_fill(), 2.0));
|
||||
n.appearance.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Inside;
|
||||
let mut g = group(doc, vec![n], false);
|
||||
g.appearance.effects = vec![drop_shadow()];
|
||||
g
|
||||
});
|
||||
assert!(p.contains("%AI9_BeginArtStyles"), "{p}");
|
||||
assert!(warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A path linked to a graphic style with a drop shadow and a stroke with a width profile is written
|
||||
/// with that style, which holds the profile, and doesn't warn; it reads back linked, with its
|
||||
/// profile. A linked path whose fill is half opaque in Screen mode isn't written with its style, which
|
||||
/// the reader reads on no object, and warns that the fill's transparency is left out.
|
||||
#[test]
|
||||
fn a_linked_paths_drawn_look_warns_only_for_what_its_style_leaves_out() {
|
||||
let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] };
|
||||
let linked = |doc: &mut Document, fill: FillLayer| {
|
||||
let mut stroke = StrokeLayer::new(black_fill(), 4.0);
|
||||
stroke.profile = Some(profile.clone());
|
||||
let ap = Appearance {
|
||||
items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)],
|
||||
effects: vec![drop_shadow()],
|
||||
contents_index: None,
|
||||
};
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new("Linked", 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);
|
||||
n
|
||||
};
|
||||
let (n, warnings, p) = read_one(|doc| linked(doc, FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9)))));
|
||||
assert!(p.contains("1 (Linked) XW"), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}");
|
||||
assert!(n.extras().graphic_style.is_some(), "{n:?}");
|
||||
assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile.clone()));
|
||||
let (_, warnings, p) = read_one(|doc| {
|
||||
let mut fill = FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9)));
|
||||
fill.opacity = 0.5;
|
||||
fill.blend = BlendMode::Screen;
|
||||
linked(doc, fill)
|
||||
});
|
||||
assert!(!p.contains("1 (Linked) XW"), "{p}");
|
||||
assert!(warnings.iter().any(|w| w == writer::ITEM_TRANSPARENCY), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -1037,10 +1037,11 @@ impl<'a> Writer<'a> {
|
||||
AppearanceItem::Fill(f) => self.fill_object(path, rule, f, xy),
|
||||
AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy),
|
||||
AppearanceItem::Stroke(s) => {
|
||||
// In a drawn look a brushed stroke is kept by the object's art style, which is written
|
||||
// only when the reader reads the brush back from it; a styled group's members are
|
||||
// written again after the look, and warn there.
|
||||
if !(self.in_look && s.brush.is_some()) {
|
||||
// In a styled path's or compound path's drawn look the stroke, brushed or not, is kept
|
||||
// by the object's art style, which is written only when the reader reads the object's
|
||||
// appearance (and its brush) back from it. A styled group's style holds no fills or
|
||||
// strokes: its members are written again after the look, and warn there.
|
||||
if !self.in_look {
|
||||
self.warn(STROKES);
|
||||
}
|
||||
self.wrote_fixed = true;
|
||||
|
||||
@@ -158,6 +158,8 @@ pub enum HiddenObjects {
|
||||
pub struct LegacyAiOutput {
|
||||
pub bytes: Vec<u8>,
|
||||
pub warnings: Vec<String>,
|
||||
/// Notes on what the options asked for: hidden objects left out or written visible.
|
||||
pub setting_notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Whether image node `n` of `doc` is written as a 1-bit image mask in its colorize color
|
||||
@@ -269,7 +271,7 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
|
||||
// The page trailer as the specification's script has it (§3); the document trailer takes the
|
||||
// prolog's dictionary off the dictionary stack (its procedure set termination, §3).
|
||||
s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nend\n%%EOF\n");
|
||||
Ok(LegacyAiOutput { bytes: s.into_bytes(), warnings: w.warnings })
|
||||
Ok(LegacyAiOutput { bytes: s.into_bytes(), warnings: w.warnings, setting_notes: w.setting_notes })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1894,12 +1894,12 @@ fn hidden_objects_go_to_a_hidden_layer_above_their_own() {
|
||||
#[test]
|
||||
fn hidden_objects_can_be_left_out_or_shown() {
|
||||
let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Omit, ..LegacyAiOptions::default() });
|
||||
assert!(out.warnings.iter().any(|w| w == HIDDEN), "{:?}", out.warnings);
|
||||
assert!(out.setting_notes.iter().any(|w| w == HIDDEN), "{:?}", out.setting_notes);
|
||||
let doc = crate::import(&out.bytes).unwrap().document;
|
||||
assert_eq!(doc.layers.len(), 2);
|
||||
assert_eq!(children(&children(&doc.layers[0])[0]).len(), 1);
|
||||
let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Visible, ..LegacyAiOptions::default() });
|
||||
assert!(out.warnings.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.warnings);
|
||||
assert!(out.setting_notes.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.setting_notes);
|
||||
let doc = crate::import(&out.bytes).unwrap().document;
|
||||
assert_eq!(doc.layers.len(), 2);
|
||||
let pair = children(&children(&doc.layers[0])[0]);
|
||||
|
||||
@@ -24,6 +24,8 @@ pub(super) struct Writer<'a> {
|
||||
/// The name each spot color (by its swatch's name) is written with ([`Self::spot`]).
|
||||
spot_names: Vec<(String, String)>,
|
||||
pub(super) warnings: Vec<String>,
|
||||
/// Notes on what the options asked for ([`crate::LegacyAiOutput::setting_notes`]).
|
||||
pub(super) setting_notes: Vec<String>,
|
||||
/// The lock state the last `A` set.
|
||||
locked: bool,
|
||||
/// Symbols and live objects being written inside one another.
|
||||
@@ -56,6 +58,7 @@ impl<'a> Writer<'a> {
|
||||
spots: vec![],
|
||||
spot_names: vec![],
|
||||
warnings: vec![],
|
||||
setting_notes: vec![],
|
||||
locked: false,
|
||||
nest: 0,
|
||||
hidden: o.hidden,
|
||||
@@ -68,12 +71,21 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Note `w`, once, about something of the document that the file leaves out or that reaches
|
||||
/// it changed. A note about an option goes to [`Self::note_setting`].
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Note `w`, once, about hidden objects left out or written visible as an option asks.
|
||||
fn note_setting(&mut self, w: &str) {
|
||||
if !self.setting_notes.iter().any(|x| x == w) {
|
||||
self.setting_notes.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn line(&mut self, s: &str) {
|
||||
self.body.push_str(s);
|
||||
self.body.push('\n');
|
||||
@@ -225,7 +237,7 @@ impl<'a> Writer<'a> {
|
||||
if !n.visible && !self.in_hidden {
|
||||
match self.hidden {
|
||||
HiddenObjects::Omit => {
|
||||
self.warn(HIDDEN);
|
||||
self.note_setting(HIDDEN);
|
||||
return;
|
||||
}
|
||||
HiddenObjects::Layer => {
|
||||
@@ -233,7 +245,7 @@ impl<'a> Writer<'a> {
|
||||
self.hidden_art.push((n.clone(), locked || n.locked));
|
||||
return;
|
||||
}
|
||||
HiddenObjects::Visible => self.warn(HIDDEN_SHOWN),
|
||||
HiddenObjects::Visible => self.note_setting(HIDDEN_SHOWN),
|
||||
}
|
||||
}
|
||||
if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) {
|
||||
|
||||
@@ -195,6 +195,8 @@ pub struct EpsOutput {
|
||||
pub bytes: Vec<u8>,
|
||||
/// What was approximated or left out.
|
||||
pub warnings: Vec<String>,
|
||||
/// Notes on the options: the preview or thumbnail asked for that was left out.
|
||||
pub setting_notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// The `%%BoundingBox` of `region` with PostScript's origin at document point `origin`: whole
|
||||
@@ -229,11 +231,11 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
let page = scene::Scene::new(doc, opts).run();
|
||||
let mut warnings = page.warnings;
|
||||
let mut setting_notes = vec![];
|
||||
let thumb = thumbnail.and_then(|t| match tiff::png(t) {
|
||||
Ok(png) => Some(png),
|
||||
Err(e) => {
|
||||
warnings.push(format!("the thumbnail was left out: {e}"));
|
||||
setting_notes.push(format!("the thumbnail was left out: {e}"));
|
||||
None
|
||||
}
|
||||
});
|
||||
@@ -241,7 +243,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb
|
||||
let bytes = match (opts.preview, preview) {
|
||||
(Preview::None, _) => ps.into_bytes(),
|
||||
(_, None) => {
|
||||
warnings.push("the preview was left out: nothing was rendered for it".into());
|
||||
setting_notes.push("the preview was left out: nothing was rendered for it".into());
|
||||
ps.into_bytes()
|
||||
}
|
||||
(kind, Some(img)) => {
|
||||
@@ -249,7 +251,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb
|
||||
dos_eps(ps.as_bytes(), &tiff)?
|
||||
}
|
||||
};
|
||||
Ok(EpsOutput { bytes, warnings })
|
||||
Ok(EpsOutput { bytes, warnings: page.warnings, setting_notes })
|
||||
}
|
||||
|
||||
/// Comment lines that start a data section the file carries (`%VectorCraft_BeginData: <name>`)
|
||||
|
||||
@@ -181,7 +181,7 @@ pub fn print(pages: &[PrintPage], order: &[usize], job: &PrintJob) -> Result<Eps
|
||||
s.push_str(page);
|
||||
}
|
||||
s.push_str("%%Trailer\n%%EOF\n");
|
||||
Ok(EpsOutput { bytes: s.into_bytes(), warnings })
|
||||
Ok(EpsOutput { bytes: s.into_bytes(), warnings, setting_notes: vec![] })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1052,7 +1052,7 @@ fn a_tiff_preview_sits_behind_the_binary_header() {
|
||||
}
|
||||
// Asked for but not rendered: the file has no preview, and says so.
|
||||
let out = export(&d, &EpsOptions { preview: Preview::TiffColor, ..opts(&d) }, None, None).unwrap();
|
||||
assert!(out.bytes.starts_with(b"%!PS") && out.warnings.iter().any(|w| w.contains("preview")));
|
||||
assert!(out.bytes.starts_with(b"%!PS") && out.setting_notes.iter().any(|w| w.contains("preview")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,8 +7,9 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
|
||||
export_with_report(doc, opts).map(|r| r.bytes)
|
||||
}
|
||||
|
||||
/// Like [`export`], also returning warnings: options accepted but not applied yet (see
|
||||
/// [`crate::PdfSettings::warnings`]) and features approximated or dropped.
|
||||
/// Like [`export`], also returning warnings, features approximated or dropped, and notes on the
|
||||
/// options: options accepted but not applied yet (see [`crate::PdfSettings::warnings`]) or applied
|
||||
/// another way.
|
||||
pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
|
||||
// Type in the document's composite fonts is drawn in them, whether or not an app has the document open.
|
||||
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
|
||||
@@ -63,9 +64,10 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
|
||||
let display_doc_title = opts.display_doc_title || opts.title.as_deref().is_some_and(|title| !title.trim().is_empty());
|
||||
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
|
||||
// Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it).
|
||||
let mut warnings = set.warnings();
|
||||
let mut warnings = vec![];
|
||||
let mut settings = set.warnings();
|
||||
if raised.is_some() {
|
||||
warnings.push(if opts.settings.compatibility == Compatibility::Pdf14 {
|
||||
settings.push(if opts.settings.compatibility == Compatibility::Pdf14 {
|
||||
"PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6".into()
|
||||
} else {
|
||||
"PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6".into()
|
||||
@@ -75,7 +77,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
|
||||
if let Some(native) = native {
|
||||
w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, now.map(date_time)));
|
||||
} else if set.preserve_editing {
|
||||
warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into());
|
||||
settings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into());
|
||||
}
|
||||
|
||||
let mut ex = Exporter::new(doc, set);
|
||||
@@ -85,7 +87,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
|
||||
// A knockout page group draws the layers' objects one by one, not layer by layer.
|
||||
ex.layers = set.writes_layers() && !doc.page_knockout;
|
||||
if set.writes_layers() && doc.page_knockout {
|
||||
warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into());
|
||||
settings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into());
|
||||
}
|
||||
ex.overprint = set.advanced.overprint == crate::Overprint::Preserve;
|
||||
let bleed = set.bleed_of(doc);
|
||||
@@ -142,7 +144,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
|
||||
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
|
||||
let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible);
|
||||
w.absorb(ex);
|
||||
let (bytes, more) = match w.finish(left_out, display_doc_title) {
|
||||
let (bytes, more, catalog) = match w.finish(left_out, display_doc_title) {
|
||||
Ok(done) => done,
|
||||
Err(e) => return Err(writer_error(doc, opts, left_out, &long, e)),
|
||||
};
|
||||
@@ -155,11 +157,13 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
|
||||
// The writer's metadata has no copyright: it is added to the XMP packet.
|
||||
let bytes = crate::post::rights(bytes, &doc.metadata)?;
|
||||
warnings.extend(more);
|
||||
settings.extend(catalog);
|
||||
let bytes = crate::post::user_units(bytes, &units)?;
|
||||
// A PDF 1.5 file written as PDF 1.6 keeps the encryption PDF 1.5 has (128-bit RC4).
|
||||
let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?;
|
||||
let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut settings)?;
|
||||
warnings.dedup();
|
||||
Ok(ExportReport { bytes, warnings })
|
||||
settings.dedup();
|
||||
Ok(ExportReport { bytes, warnings, setting_notes: settings })
|
||||
}
|
||||
|
||||
/// The artboards `opts` exports, in page order, with the boxes of their pages: Marks and Bleeds
|
||||
|
||||
@@ -26,6 +26,10 @@ pub(crate) struct Writer {
|
||||
images: Vec<(String, Image)>,
|
||||
}
|
||||
|
||||
/// What [`Writer::finish`] gives: the file, the warnings of its drawing, and the notes on the
|
||||
/// output intent asked for ([`crate::ExportReport::setting_notes`]).
|
||||
pub(crate) type Finished = (Vec<u8>, Vec<String>, Vec<String>);
|
||||
|
||||
/// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]),
|
||||
/// which [`Self::view`] maps onto the page.
|
||||
pub(crate) struct Sheet<'a> {
|
||||
@@ -252,10 +256,11 @@ impl Writer {
|
||||
}
|
||||
|
||||
/// The finished file (with the output intent and Trapped entries, and checked against its
|
||||
/// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK
|
||||
/// standard), the warnings of its drawing, and the notes on the output intent asked for
|
||||
/// ([`crate::ExportReport::setting_notes`]). PDF/X-1a spot colours keep their CMYK
|
||||
/// alternates. When the writer couldn't decode one of the images, its key is added to
|
||||
/// `left_out` ([`leaving_out_undecodable`]) and the writer's error is returned.
|
||||
pub(crate) fn finish(mut self, left_out: &mut HashSet<String>, display_doc_title: bool) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||||
pub(crate) fn finish(mut self, left_out: &mut HashSet<String>, display_doc_title: bool) -> Result<Finished, PdfError> {
|
||||
let images = std::mem::take(&mut self.images);
|
||||
let mut bytes = self.pdf.finish().map_err(|e| {
|
||||
if let write::WriteError::Serialization(k) = &e
|
||||
@@ -270,10 +275,9 @@ impl Writer {
|
||||
}
|
||||
let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only();
|
||||
let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes };
|
||||
let (bytes, more) = self.out.write_catalog(bytes, display_doc_title)?;
|
||||
self.warnings.extend(more);
|
||||
let (bytes, settings) = self.out.write_catalog(bytes, display_doc_title)?;
|
||||
self.warnings.extend(self.rgb_in_cmyk.warning());
|
||||
Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings))
|
||||
Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings, settings))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,10 @@ pub struct ImportReport {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExportReport {
|
||||
pub bytes: Vec<u8>,
|
||||
/// What the file leaves out of the document or writes differently.
|
||||
pub warnings: Vec<String>,
|
||||
/// Notes on the options: one the export didn't apply or applied another way.
|
||||
pub setting_notes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
|
||||
@@ -349,7 +349,7 @@ impl ColorOut {
|
||||
}
|
||||
|
||||
/// `pdf` with the output intent and Trapped entries, and a PDF/X file's identification
|
||||
/// ([`crate::pdfx`]) → (the file, warnings).
|
||||
/// ([`crate::pdfx`]) → (the file, notes on the output intent asked for).
|
||||
pub(crate) fn write_catalog(&self, pdf: Vec<u8>, display_doc_title: bool) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||||
let cat = self.catalog.as_ref();
|
||||
if cat.is_none() && !display_doc_title {
|
||||
|
||||
@@ -204,13 +204,13 @@ fn linearised(pdf: &[u8], set: &PdfSettings) -> Result<Vec<u8>, PdfError> {
|
||||
}
|
||||
|
||||
/// The file the export wrote (`pdf`), finished as `opts` say; `editing`: it carries the editing
|
||||
/// data, to seal once the pages are final. What can't be done comes back in `warnings`.
|
||||
pub(crate) fn finish(pdf: Vec<u8>, opts: &PdfOptions, editing: bool, warnings: &mut Vec<String>) -> Result<Vec<u8>, PdfError> {
|
||||
/// data, to seal once the pages are final. The options it can't apply come back in `settings`.
|
||||
pub(crate) fn finish(pdf: Vec<u8>, opts: &PdfOptions, editing: bool, settings: &mut Vec<String>) -> Result<Vec<u8>, PdfError> {
|
||||
let set = &opts.settings;
|
||||
let pdf = match (set.thumbnails, opts.thumbnails.is_empty()) {
|
||||
(true, false) => thumbnails(pdf, &opts.thumbnails, set.standard.is_pdfx())?,
|
||||
(true, true) => {
|
||||
warnings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into());
|
||||
settings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into());
|
||||
pdf
|
||||
}
|
||||
(false, _) => pdf,
|
||||
@@ -229,7 +229,7 @@ pub(crate) fn finish(pdf: Vec<u8>, opts: &PdfOptions, editing: bool, warnings: &
|
||||
match linearised(&pdf, set) {
|
||||
Ok(out) => Ok(out),
|
||||
Err(e) => {
|
||||
warnings.push(format!("the file isn't optimized for fast web view: {e}"));
|
||||
settings.push(format!("the file isn't optimized for fast web view: {e}"));
|
||||
crate::encrypt::protect(pdf, set)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,10 +470,10 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet<Strin
|
||||
for ex in exporters {
|
||||
w.absorb(ex);
|
||||
}
|
||||
let (bytes, more) = w.finish(left_out, false)?;
|
||||
let (bytes, more, settings) = w.finish(left_out, false)?;
|
||||
let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted, invisible)?;
|
||||
let mut warnings = plan.warnings;
|
||||
for m in more {
|
||||
for m in more.into_iter().chain(settings) {
|
||||
if !warnings.contains(&m) {
|
||||
warnings.push(m);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@ fn the_legacy_name_is_read_and_plain_pdfs_have_no_editing_data() {
|
||||
assert_eq!(editing(&plain), None);
|
||||
assert_eq!(import_with_report(&plain, &ImportOptions::default()).unwrap().native, None);
|
||||
let r = pdf_with(None, true);
|
||||
assert!(r.warnings.len() == 1 && r.warnings[0].contains("Preserve editing"), "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(r.setting_notes.len() == 1 && r.setting_notes[0].contains("Preserve editing"), "{:?}", r.setting_notes);
|
||||
assert_eq!(editing(&r.bytes), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -212,15 +212,15 @@ fn without_pdf_layers_the_layers_are_plain_page_content() {
|
||||
oc_properties(&r.bytes, None, |props, _| has = props.is_some());
|
||||
assert_eq!(has, v["createLayers"] == json!(true) && warning.is_none(), "{v}");
|
||||
match warning {
|
||||
Some(w) => assert!(r.warnings.iter().any(|x| x.contains(w)), "{v}: {:?}", r.warnings),
|
||||
None => assert!(r.warnings.is_empty(), "{v}: {:?}", r.warnings),
|
||||
Some(w) => assert!(r.setting_notes.iter().any(|x| x.contains(w)), "{v}: {:?}", r.setting_notes),
|
||||
None => assert!(r.warnings.is_empty() && r.setting_notes.is_empty(), "{v}: {:?} {:?}", r.warnings, r.setting_notes),
|
||||
}
|
||||
}
|
||||
// A knockout page group draws objects, not layers.
|
||||
d.page_knockout = true;
|
||||
let r = export(&d, json!({"createLayers": true}));
|
||||
oc_properties(&r.bytes, None, |props, _| assert!(props.is_none()));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("knockout page group")), "{:?}", r.warnings);
|
||||
assert!(r.setting_notes.iter().any(|w| w.contains("knockout page group")), "{:?}", r.setting_notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -251,8 +251,9 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() {
|
||||
assert!(r.bytes.starts_with(format!("%PDF-{header}").as_bytes()), "{}", c.label());
|
||||
assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.938], "{}", c.label());
|
||||
assert!((raw_meters(&r.bytes).0 - 20.0).abs() < 0.001, "{}", c.label());
|
||||
let raised = r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6"));
|
||||
assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.warnings);
|
||||
let raised =
|
||||
r.setting_notes.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6"));
|
||||
assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.setting_notes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,11 +289,11 @@ fn pdfa2b_at_pdf_1_4_writes_a_long_page_as_pdf_1_6_within_14400_units() {
|
||||
assert!(media[2] - media[0] <= 14_400.0 && media[3] - media[1] <= 14_400.0, "{side} {in_points}: {media:?}");
|
||||
assert!(((media[2] - media[0]) * unit[0] - side).abs() < side * 0.001, "{side} {in_points}: {media:?} {unit:?}");
|
||||
let raised = "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6";
|
||||
assert!(r.warnings.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.warnings);
|
||||
assert!(r.setting_notes.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.setting_notes);
|
||||
}
|
||||
let r = report(&sized(14_400.0, 300.0), set.clone()).unwrap();
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.4"), "{in_points}");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.warnings);
|
||||
assert!(!r.setting_notes.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.setting_notes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +501,7 @@ fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_
|
||||
assert!(r.bytes.starts_with(format!("%PDF-{}", c.id()).as_bytes()), "{}", c.label());
|
||||
assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::<f64>::new(), "{}", c.label());
|
||||
assert!((raw_meters(&r.bytes).0 - 10.0).abs() < 0.001, "{}", c.label());
|
||||
assert!(!r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.warnings);
|
||||
assert!(!r.setting_notes.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.setting_notes);
|
||||
assert_eq!(long_page_warnings(&r).len(), 1, "{}: {:?}", c.label(), r.warnings);
|
||||
if matches!(c, Compatibility::Pdf13 | Compatibility::Pdf14) {
|
||||
assert_eq!(r.bytes, report(&d, set).unwrap().bytes, "{}", c.label());
|
||||
|
||||
@@ -463,7 +463,7 @@ fn pdf_a_keeps_its_own_output_intent() {
|
||||
let r = pdf(&d, json!({"standard": "pdfA2b", "output": {"outputIntent": GENERIC_CMYK, "trapped": true}}));
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
assert!(text.contains("GTS_PDFA1") && !text.contains("GTS_PDFX") && !text.contains("/Trapped"));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("PDF/A")), "{:?}", r.warnings);
|
||||
assert!(r.setting_notes.iter().any(|w| w.contains("PDF/A")), "{:?}", r.setting_notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -69,7 +69,7 @@ fn pdf_1_3_files_are_encrypted_with_40_bit_rc4() {
|
||||
let o = options(json!({"compatibility": "1.3", "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": true}}));
|
||||
let r = export_with_report(&doc(1.0), &o).unwrap();
|
||||
assert!(String::from_utf8_lossy(&r.bytes).contains("/Filter/Standard/V 1/R 2/Length 40/P "));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.warnings);
|
||||
assert!(r.setting_notes.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.setting_notes);
|
||||
assert!(matches!(import(&r.bytes), Err(PdfError::NeedsPassword)));
|
||||
for password in ["open", "owner"] {
|
||||
let opened = import_with_report(&r.bytes, &ImportOptions { password: Some(password.into()), ..Default::default() }).unwrap();
|
||||
|
||||
@@ -349,7 +349,7 @@ fn every_copy_on_a_sheet_draws_the_same_symbol_instances() {
|
||||
w.page(&mut ex, &sheet).unwrap();
|
||||
assert_eq!(ex.symbol_limit, 10, "the limit is the export's again after the sheet");
|
||||
w.absorb(ex);
|
||||
let (bytes, _) = w.finish(&mut std::collections::HashSet::new(), false).unwrap();
|
||||
let (bytes, ..) = w.finish(&mut std::collections::HashSet::new(), false).unwrap();
|
||||
let p = pages(&bytes);
|
||||
let left = p[0].1.iter().filter(|(r, _)| r.x0 < 200.0).count();
|
||||
assert_eq!((left, p[0].1.len() - left), (3, 3), "one instance in each copy: {:?}", p[0].1);
|
||||
|
||||
@@ -689,7 +689,7 @@ fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() {
|
||||
let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Subset", style(24.0))]);
|
||||
let settings: PdfSettings = serde_json::from_value(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 40}})).unwrap();
|
||||
let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("subset")), "{:?}", r.warnings);
|
||||
assert!(r.setting_notes.iter().any(|w| w.contains("subset")), "{:?}", r.setting_notes);
|
||||
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Subset"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ fn checks_refuse_what_the_writer_cant_honour() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_not_applied_yet_come_back_as_warnings() {
|
||||
fn options_not_applied_yet_come_back_as_setting_notes() {
|
||||
assert!(PdfSettings::default().warnings().is_empty());
|
||||
for (v, word) in [
|
||||
(json!({"createLayers": true, "compatibility": "1.4"}), "PDF 1.5"),
|
||||
@@ -110,7 +110,8 @@ fn options_not_applied_yet_come_back_as_warnings() {
|
||||
let r =
|
||||
export_with_report(&doc(), &PdfOptions { settings: settings(json!({"thumbnails": true, "marks": {"trim": true}})), ..Default::default() })
|
||||
.unwrap();
|
||||
// The writer embeds the thumbnails it is given: without them, the one warning says so.
|
||||
assert!(r.warnings.len() == 1 && r.warnings[0].contains("thumbnails need the pages drawn"), "{:?}", r.warnings);
|
||||
// The writer embeds the thumbnails it is given: without them, the one note on the options says so.
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(r.setting_notes.len() == 1 && r.setting_notes[0].contains("thumbnails need the pages drawn"), "{:?}", r.setting_notes);
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.7"));
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ fn thumbnails_are_embedded_on_every_page() {
|
||||
assert_eq!(import(&r.bytes).unwrap().artboards.len(), 3);
|
||||
// Asked for without the pages drawn: none, with a warning. Thumbnails too large are refused.
|
||||
let r = export_with_report(&d, &options(json!({"thumbnails": true}))).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none());
|
||||
assert!(r.setting_notes.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none());
|
||||
o.thumbnails[1] = thumb(107, 20, 0);
|
||||
assert!(matches!(export(&d, &o), Err(PdfError::BadSetting(_))));
|
||||
}
|
||||
|
||||
+55
-9
@@ -206,6 +206,14 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
|
||||
p
|
||||
}
|
||||
|
||||
/// The decimals, at least `least` and at most 12, for numbers that place a period of length
|
||||
/// `period` repeated out to `reach` (in the same units): each period then rounds by at most
|
||||
/// 10^-decimals, and over the `reach / period` periods that adds up to at most a hundredth of one.
|
||||
fn period_decimals(period: f64, reach: f64, least: u8) -> u8 {
|
||||
let d = (100.0 * reach / (period * period)).log10().ceil();
|
||||
if d.is_nan() || d == f64::INFINITY { 12 } else { d.clamp(f64::from(least), 12.0) as u8 }
|
||||
}
|
||||
|
||||
/// A colour at `opacity`: `#rrggbb` when opaque, else `rgba()`.
|
||||
fn color_css(c: &Color, opacity: f32) -> String {
|
||||
if opacity >= 1.0 {
|
||||
@@ -247,13 +255,22 @@ impl CssUnits {
|
||||
|
||||
/// `points` in these units (`0` without a unit).
|
||||
fn len(self, points: f64) -> String {
|
||||
let per_point = match self {
|
||||
self.len_to(points, 3)
|
||||
}
|
||||
|
||||
/// One point in these units.
|
||||
fn per_point(self) -> f64 {
|
||||
match self {
|
||||
CssUnits::Px | CssUnits::Pt => 1.0,
|
||||
CssUnits::Mm => 25.4 / 72.0,
|
||||
CssUnits::Cm => 2.54 / 72.0,
|
||||
CssUnits::In => 1.0 / 72.0,
|
||||
};
|
||||
match fmt_num(points * per_point, 3) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::len`] with `decimals` decimals.
|
||||
fn len_to(self, points: f64, decimals: u8) -> String {
|
||||
match fmt_num(points * self.per_point(), decimals) {
|
||||
zero if zero == "0" => zero,
|
||||
v => format!("{v}{}", self.name()),
|
||||
}
|
||||
@@ -582,9 +599,21 @@ impl Rules<'_> {
|
||||
}
|
||||
_ => (unrolled, geom, ""),
|
||||
};
|
||||
let stops = |pos: &dyn Fn(f32) -> f64| {
|
||||
// A repeating gradient's period is the span from its first stop to its last, repeated
|
||||
// across the box (from 0% to `reach`%): its positions get at least four decimals, and more
|
||||
// when the box holds so many periods that their rounding would add up to more than a
|
||||
// hundredth of one ([`period_decimals`]).
|
||||
let span = gradient.expanded().next().zip(gradient.expanded().last()).map(|(a, z)| (a.0, z.0));
|
||||
let decimals = |pos: &dyn Fn(f32) -> f64, reach: f64| match span {
|
||||
Some((a, z)) if !repeating.is_empty() => {
|
||||
let (p0, p1) = (pos(a) * 100.0, pos(z) * 100.0);
|
||||
period_decimals((p1 - p0).abs(), p0.abs().max((reach - p0).abs()), 4)
|
||||
}
|
||||
_ => 2,
|
||||
};
|
||||
let stops = |pos: &dyn Fn(f32) -> f64, decimals: u8| {
|
||||
let stops: Vec<String> =
|
||||
gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, 2))).collect();
|
||||
gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, decimals))).collect();
|
||||
stops.join(", ")
|
||||
};
|
||||
match g.gradient.kind {
|
||||
@@ -601,7 +630,7 @@ impl Rules<'_> {
|
||||
let line = (b.width() * dir.x).abs() + (b.height() * dir.y).abs();
|
||||
let from = (geom.start - b.center()).dot(dir);
|
||||
let pos = |t: f32| if line > 1e-9 { (from + t as f64 * length) / line + 0.5 } else { t as f64 };
|
||||
Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos)))
|
||||
Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos, decimals(&pos, 100.0))))
|
||||
}
|
||||
// CSS has no diamond gradient: the ellipse through the square's corners stands in.
|
||||
GradientKind::Radial | GradientKind::Diamond => {
|
||||
@@ -611,13 +640,30 @@ impl Rules<'_> {
|
||||
} else if geom.focal.is_some() {
|
||||
self.warn("radial gradients start at their centre (an off-centre focal point is left out)");
|
||||
}
|
||||
// The positions are a share of the radius, and the box reaches out to its farthest
|
||||
// corner. A repeating gradient's period scales with its radius, so the radius gets
|
||||
// the decimals that keep the periods in place out to that corner too.
|
||||
let units = self.opts.units;
|
||||
let small = r * geom.aspect.abs().min(1.0);
|
||||
let far = [(b.x0, b.y0), (b.x1, b.y0), (b.x0, b.y1), (b.x1, b.y1)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| (Point::new(x, y) - geom.start).hypot())
|
||||
.fold(0.0, f64::max);
|
||||
let reach = if small > 1e-12 { 100.0 * far / small } else { 0.0 };
|
||||
let pos = |t: f32| t as f64;
|
||||
let len = match span {
|
||||
Some((a, z)) if !repeating.is_empty() => {
|
||||
period_decimals(small * f64::from(z - a).abs() * units.per_point(), far * units.per_point(), 3)
|
||||
}
|
||||
_ => 3,
|
||||
};
|
||||
let shape = if (geom.aspect - 1.0).abs() < 1e-9 {
|
||||
format!("circle {}", self.len(r))
|
||||
format!("circle {}", units.len_to(r, len))
|
||||
} else {
|
||||
format!("ellipse {} {}", self.len(r), self.len(r * geom.aspect))
|
||||
format!("ellipse {} {}", units.len_to(r, len), units.len_to(r * geom.aspect, len))
|
||||
};
|
||||
let at = format!("at {} {}", self.len(geom.start.x - b.x0), self.len(geom.start.y - b.y0));
|
||||
Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&|t| t as f64)))
|
||||
Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&pos, decimals(&pos, reach))))
|
||||
}
|
||||
GradientKind::Conic => {
|
||||
// CSS sweeps clockwise from the `from` angle, 0deg straight up
|
||||
|
||||
@@ -103,6 +103,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
region_boxes: None,
|
||||
art_reach: HashMap::new(),
|
||||
warnings: vec![],
|
||||
setting_notes: vec![],
|
||||
id_prefix,
|
||||
linked: Vec::new(),
|
||||
instance_xf: Affine::IDENTITY,
|
||||
@@ -142,7 +143,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
let variables = w.variable_sets();
|
||||
// XML names cannot use character references. Preserve names that Latin-1 cannot encode.
|
||||
let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names {
|
||||
w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8");
|
||||
w.note_setting("Unicode data attribute names require UTF-8; SVG was written in UTF-8");
|
||||
Encoding::Utf8
|
||||
} else {
|
||||
opts.encoding
|
||||
@@ -229,7 +230,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
out.push_str(&w.body);
|
||||
out.push_str("</svg>");
|
||||
out.push_str(nl);
|
||||
Output { svg: out, linked: w.linked, warnings: w.warnings, encoding }
|
||||
Output { svg: out, linked: w.linked, warnings: w.warnings, setting_notes: w.setting_notes, encoding }
|
||||
}
|
||||
|
||||
type ColorizedKey = (String, u32, u32, Option<[u8; 3]>);
|
||||
@@ -274,6 +275,8 @@ struct Writer<'a> {
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
/// Features written approximately (once each).
|
||||
warnings: Vec<String>,
|
||||
/// Notes on how the options were applied (once each).
|
||||
setting_notes: Vec<String>,
|
||||
/// Prepended to every id and class name ([`ObjectIds::Unique`]).
|
||||
id_prefix: &'a str,
|
||||
/// Image files the SVG links to ([`ImageMode::Link`]).
|
||||
|
||||
@@ -528,11 +528,21 @@ impl Writer<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Note `w`, once, about something of the document that the file leaves out or that reaches
|
||||
/// it changed ([`crate::Output::warnings`]). A note about an option goes to [`Self::note_setting`].
|
||||
pub(super) fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Note `w`, once, about an option that wasn't applied or was applied another way while the
|
||||
/// art reaches the file as it looks ([`crate::Output::setting_notes`]).
|
||||
pub(super) fn note_setting(&mut self, w: &str) {
|
||||
if !self.setting_notes.iter().any(|x| x == w) {
|
||||
self.setting_notes.push(w.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An element that has both its own classes and a generated `cls-N` would write `class` twice:
|
||||
|
||||
@@ -522,7 +522,7 @@ impl Writer<'_> {
|
||||
continue;
|
||||
};
|
||||
if !font.subset {
|
||||
self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting"));
|
||||
self.note_setting(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting"));
|
||||
}
|
||||
let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1;
|
||||
let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() };
|
||||
|
||||
@@ -299,6 +299,9 @@ pub struct Output {
|
||||
pub linked: Vec<LinkedImage>,
|
||||
/// Features the export approximates.
|
||||
pub warnings: Vec<String>,
|
||||
/// Notes on the options: one the export didn't apply or applied another way (an encoding, a
|
||||
/// font embedded whole).
|
||||
pub setting_notes: Vec<String>,
|
||||
/// The encoding [`Self::bytes`] writes.
|
||||
pub encoding: Encoding,
|
||||
}
|
||||
@@ -366,10 +369,11 @@ pub fn export(doc: &Document, opts: &ExportOptions) -> String {
|
||||
export_full(doc, opts, None).svg
|
||||
}
|
||||
|
||||
/// Export a document as an SVG string, also returning warnings about approximated features.
|
||||
/// Export a document as an SVG string, also returning warnings about approximated features,
|
||||
/// then the notes on the options ([`Output::setting_notes`]).
|
||||
pub fn export_with_report(doc: &Document, opts: &ExportOptions) -> (String, Vec<String>) {
|
||||
let o = export_full(doc, opts, None);
|
||||
(o.svg, o.warnings)
|
||||
(o.svg, o.warnings.into_iter().chain(o.setting_notes).collect())
|
||||
}
|
||||
|
||||
/// Export a document as SVG plus the image files it links to. `native`: the document in the
|
||||
|
||||
@@ -359,6 +359,28 @@ fn spread_gradients_give_repeating_css_gradients() {
|
||||
css(GradientKind::Linear, GradientSpread::Reflect, across).as_deref(),
|
||||
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%, #000000 25%, #ffffff 50%)")
|
||||
);
|
||||
// A period that is not a whole hundredth of the box keeps four decimals, so it does not drift
|
||||
// across the box.
|
||||
let short = GradientGeom { end: Point::new(10.3452, 30.0), ..across };
|
||||
assert_eq!(
|
||||
css(GradientKind::Linear, GradientSpread::Repeat, short).as_deref(),
|
||||
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 2.5863%)")
|
||||
);
|
||||
// About 1,000 periods across the box: six decimals, so their rounding adds up to at most a
|
||||
// hundredth of a period at the far side (with four, `0.1%` would be almost half a period out
|
||||
// there).
|
||||
let tiny = GradientGeom { end: Point::new(0.400196, 30.0), ..across };
|
||||
assert_eq!(
|
||||
css(GradientKind::Linear, GradientSpread::Repeat, tiny).as_deref(),
|
||||
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 0.100049%)")
|
||||
);
|
||||
// A radial period of 0.03085 px out to a corner 67 px away: its radius keeps the decimals that
|
||||
// hold the periods in place there.
|
||||
let dot = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(40.1234, 30.0), aspect: 1.0, focal: None };
|
||||
assert_eq!(
|
||||
css(GradientKind::Radial, GradientSpread::Repeat, dot).as_deref(),
|
||||
Some("repeating-radial-gradient(circle 0.03085px at 40px 30px, #ffffff 0%, #000000 100%)")
|
||||
);
|
||||
let ring = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None };
|
||||
assert_eq!(
|
||||
css(GradientKind::Radial, GradientSpread::Reflect, ring).as_deref(),
|
||||
|
||||
@@ -98,7 +98,7 @@ fn unicode_data_names_survive_each_encoding() {
|
||||
}
|
||||
if encoding == Encoding::Latin1 {
|
||||
assert_eq!(out.encoding, Encoding::Utf8, "XML names cannot use character references");
|
||||
assert!(!out.warnings.is_empty());
|
||||
assert!(!out.setting_notes.is_empty());
|
||||
assert!(!out.svg.contains("ISO-8859-1"));
|
||||
} else {
|
||||
assert_eq!(out.encoding, encoding);
|
||||
|
||||
@@ -321,7 +321,7 @@ fn font_licences_are_respected() {
|
||||
let size = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").len();
|
||||
assert!(fonts[0].2.len() + 64 >= size, "embedded whole: {} of {size} bytes", fonts[0].2.len());
|
||||
assert!(o.warnings.iter().any(|w| w.contains("Sealed Sans 3") && w.contains("doesn't allow embedding")), "{:?}", o.warnings);
|
||||
assert!(o.warnings.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.warnings);
|
||||
assert!(o.setting_notes.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.setting_notes);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
//! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}`
|
||||
//! - `ui.dialog.set {field, value}` or `{fields: [{field, value}…]}` (all or none; setting a field
|
||||
//! the dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`];
|
||||
//! a `__` field other than the controls of the dialog's kind is an error too, so a request cannot
|
||||
//! change the command, targets or file the dialog's OK acts on: [`crate::dialogs::protected_field`];
|
||||
//! `action: confirm | cancel` then confirms or cancels that dialog in the same request and replies
|
||||
//! `{dialog, result}`) / `ui.dialog.confirm` / `ui.dialog.cancel`
|
||||
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG
|
||||
@@ -1442,6 +1444,9 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
};
|
||||
// Every field is checked before any is set, so a request sets all of them or none.
|
||||
if let Some((f, _)) = fields.iter().find(|(f, _)| !crate::dialogs::accepts_field(d, f)) {
|
||||
if crate::dialogs::protected_field(d, f) {
|
||||
return err(format!("the `{}` dialog cannot set protected field `{f}`", d.kind));
|
||||
}
|
||||
let names = crate::dialogs::field_names(d).join(", ");
|
||||
return err(format!("the `{}` dialog has no field `{f}`; its fields: {names}", d.kind));
|
||||
}
|
||||
|
||||
@@ -1161,7 +1161,8 @@ fn save_and_close_apply_a_merge_on_another_thread_first() {
|
||||
|
||||
/// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields,
|
||||
/// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard`
|
||||
/// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`.
|
||||
/// with a discard button, `preview` with a live preview and the spec's `inputs`. A `__` field that
|
||||
/// is not one of the controls of the dialog's kind is refused.
|
||||
#[test]
|
||||
fn dialog_set_refuses_a_field_the_dialog_does_not_have() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
@@ -1170,8 +1171,11 @@ fn dialog_set_refuses_a_field_the_dialog_does_not_have() {
|
||||
let r = input_request(&mut app, "ui.dialog.set", json!({"field": "discrad", "value": true}));
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert_eq!(r["error"], "the `saveChanges` dialog has no field `discrad`; its fields: discard, index, name, then, uid");
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields.clone()));
|
||||
let r = input_request(&mut app, "ui.dialog.set", json!({"field": "__note", "value": true}));
|
||||
assert_eq!(r["error"], "the `saveChanges` dialog cannot set protected field `__note`", "{r}");
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields));
|
||||
for field in ["name", "discard", "__note"] {
|
||||
for field in ["name", "discard"] {
|
||||
let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true}));
|
||||
assert_eq!(r["ok"], true, "{field}: {r}");
|
||||
}
|
||||
|
||||
@@ -484,13 +484,80 @@ fn spec(kind: &str) -> &'static DialogSpec {
|
||||
DialogKind::of(kind).map_or(&FALLBACK, DialogKind::spec)
|
||||
}
|
||||
|
||||
/// Whether `ui.dialog.set` can set `field` on `d`: a field it holds, a `__` field (the dialog's
|
||||
/// own state), `discard` on a dialog with a discard button, `preview` on one with a live preview,
|
||||
/// one of its spec's `inputs` for its kind, or one it `passes` on.
|
||||
/// The `__` fields that `ui.dialog.set` can set on `d`: controls of its kind that the dialog holds
|
||||
/// under a `__` name, each set as the control sets it. Every other `__` field of a dialog holds
|
||||
/// what its OK acts on (the command and its fixed params, the targets, a file, the preset being
|
||||
/// edited) or state the dialog keeps for itself, and `ui.dialog.set` refuses it
|
||||
/// ([`protected_field`]), so a request cannot change what OK does after a person opened the dialog.
|
||||
/// Flags that a button sets together with other fields are refused too: the style dialogs'
|
||||
/// `__reset*` (Reset Panel also clears the section's fields, so scripts run
|
||||
/// `ui.charStyleOptions.resetPanel` or `ui.paraStyleOptions.resetPanel`) and Artboard Options'
|
||||
/// `__fit`, `__sizeChoice`, `__refx` and `__refy` (set with the size and position they record), and
|
||||
/// Scribble's `__scribbleSetting` (the menu sets the preset's parameters with it).
|
||||
fn hidden_controls(d: &Dialog) -> &'static [&'static str] {
|
||||
match DialogKind::of(&d.kind) {
|
||||
// Duotone's tone row, and the 3D effects' tab, gizmo, light and Show axis.
|
||||
Some(DialogKind::Effect) => &["__gizmo", "__light", "__showAxis", "__tab", "__tone"],
|
||||
// The preset name being typed and the format whose settings the gear shows.
|
||||
Some(DialogKind::ExportForScreens) => &["__savePreset", "__settings"],
|
||||
// Keywords typed but not added yet.
|
||||
Some(DialogKind::FileInfo) => &["__keyword"],
|
||||
// The preset name being typed.
|
||||
Some(DialogKind::FlattenTransparency) => &["__presetName"],
|
||||
// The Resolution menu's Other.
|
||||
Some(DialogKind::RasterOptions | DialogKind::RasterFormatOptions | DialogKind::PsdOptions) => &["__otherPpi"],
|
||||
// The category shown, the search text and the three settings kept outside the preferences.
|
||||
Some(DialogKind::Preferences) => &["__category", "__search", "__smartGuides", "__snapToGrid", "__taskBar"],
|
||||
// The section shown, the sheet the preview shows and the preset name being typed.
|
||||
Some(DialogKind::Print | DialogKind::PrintPreset) => &["__savePresetAs", "__section", "__sheet"],
|
||||
// The section shown, All or a range of artboards, the two password checkboxes and the
|
||||
// preset name being typed.
|
||||
Some(DialogKind::SavePdf | DialogKind::PdfPreset) => {
|
||||
&["__allArtboards", "__requireOpenPassword", "__restrictPermissions", "__savePresetAs", "__section"]
|
||||
}
|
||||
// The selected rows and Randomize's seed.
|
||||
Some(DialogKind::Recolor) => &["__seed", "__sel"],
|
||||
// The view, zoom, download speed, selected color table entry and the preset name being typed.
|
||||
Some(DialogKind::SaveForWeb) => &["__color", "__kbps", "__presetName", "__view", "__zoom"],
|
||||
// The link that keeps width and height in proportion.
|
||||
Some(DialogKind::Shape | DialogKind::TransformEach) => &["__constrain"],
|
||||
// The attention filter, the Keys section, the search text and the key being recorded.
|
||||
Some(DialogKind::ShortcutImport) => &["__attention", "__keysOpen", "__query", "__recording"],
|
||||
// Set Size of Each's Width and Height checkboxes.
|
||||
Some(DialogKind::ArrangeTools) if d.kind == crate::addons::arrange::SIZE => &["__height", "__width"],
|
||||
// The search field.
|
||||
Some(DialogKind::SelectByArtboards | DialogKind::SelectByLinkStatus) => &["__search"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The controls of [`hidden_controls`] that a button adds to the dialog, which `ui.dialog.set` can
|
||||
/// set only while the dialog holds them: Save PDF's and Print's `__savePresetAs`, whose presence
|
||||
/// makes OK save a preset instead of writing the PDF or printing.
|
||||
const ADDED_CONTROLS: &[&str] = &["__savePresetAs"];
|
||||
|
||||
/// Whether `field` is a control of `d` that `ui.dialog.set` can set: one of the controls of `d`'s
|
||||
/// kind ([`hidden_controls`]), and for one that a button adds ([`ADDED_CONTROLS`]), one `d` holds.
|
||||
fn settable_control(d: &Dialog, field: &str) -> bool {
|
||||
hidden_controls(d).contains(&field) && (!ADDED_CONTROLS.contains(&field) || d.fields.contains_key(field))
|
||||
}
|
||||
|
||||
/// Whether `field` is a `__` field of `d` that `ui.dialog.set` refuses: any `__` field, held or
|
||||
/// not, except the controls of `d`'s kind ([`settable_control`]).
|
||||
pub fn protected_field(d: &Dialog, field: &str) -> bool {
|
||||
field.starts_with("__") && !settable_control(d, field)
|
||||
}
|
||||
|
||||
/// Whether `ui.dialog.set` can set `field` on `d`: a `__` field only when it is one of the controls
|
||||
/// of `d`'s kind ([`protected_field`]); otherwise a field it holds, `discard` on a dialog with a
|
||||
/// discard button, `preview` on one with a live preview, one of its spec's `inputs` for its kind,
|
||||
/// or one it `passes` on.
|
||||
pub fn accepts_field(d: &Dialog, field: &str) -> bool {
|
||||
if field.starts_with("__") {
|
||||
return !protected_field(d, field);
|
||||
}
|
||||
let spec = spec(&d.kind);
|
||||
d.fields.contains_key(field)
|
||||
|| field.starts_with("__")
|
||||
|| (field == "discard" && spec.discard.is_some())
|
||||
|| (field == "preview" && spec.preview)
|
||||
|| (spec.inputs)(&d.kind).contains(&field)
|
||||
@@ -523,11 +590,12 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds,
|
||||
/// `discard`, `preview` and the spec's `inputs` for its kind.
|
||||
/// The fields `ui.dialog.set` can set on `d`, sorted: those it holds other than its `__` ones, the
|
||||
/// `__` controls of its kind, `discard`, `preview` and the spec's `inputs` for its kind.
|
||||
pub fn field_names(d: &Dialog) -> Vec<String> {
|
||||
let spec = spec(&d.kind);
|
||||
let mut names: Vec<String> = d.fields.keys().filter(|k| !k.starts_with("__")).cloned().collect();
|
||||
names.extend(hidden_controls(d).iter().filter(|f| settable_control(d, f)).map(|f| f.to_string()));
|
||||
names.extend(spec.discard.map(|_| "discard".to_string()));
|
||||
names.extend(spec.preview.then(|| "preview".to_string()));
|
||||
names.extend((spec.inputs)(&d.kind).iter().map(|i| i.to_string()));
|
||||
|
||||
@@ -459,13 +459,17 @@ fn each_tips_sample_paints_the_brush_with_its_method() {
|
||||
assert!(tints != hue && tints != shades && none != shades, "four different samples");
|
||||
}
|
||||
|
||||
/// `ui.dialog.set` writes any field, `__def` included. With a brush definition that is not
|
||||
/// an object, Brush Options and Colorization Tips still draw.
|
||||
/// With a brush definition that is not an object, Brush Options and Colorization Tips still draw.
|
||||
/// `ui.dialog.set` refuses `__def`, which holds the brush OK saves.
|
||||
#[test]
|
||||
fn a_brush_definition_that_is_not_an_object_still_draws() {
|
||||
let (mut app, ctx) = (app(), ctx());
|
||||
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
|
||||
crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "__def", "value": 5}));
|
||||
let (req, _) = crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "__def", "value": 5}));
|
||||
let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") };
|
||||
assert_eq!(r["error"], "the `brushOptions` dialog cannot set protected field `__def`", "{r}");
|
||||
assert_ne!(app.ui.dialog.as_ref().unwrap().fields.get("__def"), Some(&json!(5)), "the request is refused");
|
||||
set(&mut app, json!({"__def": 5}));
|
||||
crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "tips", "value": true}));
|
||||
let text = shown(&mut app);
|
||||
assert!(text.contains("Scatter Brush Options") && text.contains("Colorization Tips"), "{text}");
|
||||
|
||||
@@ -262,6 +262,11 @@ fn excluded(row: &Value) -> bool {
|
||||
row["exclude"].as_bool().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The seed for the next Randomize: one past the last, held at `u64::MAX`.
|
||||
fn next_seed(d: &Dialog) -> u64 {
|
||||
(d.f64("__seed", 0.0) as u64).saturating_add(1)
|
||||
}
|
||||
|
||||
fn selection(d: &Dialog) -> Vec<usize> {
|
||||
d.fields.get("__sel").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default()
|
||||
}
|
||||
@@ -546,7 +551,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
|
||||
[("arrow-left-right", tl!("Randomly change color order"), false), ("sparkles", tl!("Randomly change saturation and brightness"), true)]
|
||||
{
|
||||
if widgets::icon_button_enabled(ui, icon, tip, false, rows.len() > 1 || sb, 24.0).clicked() {
|
||||
let seed = d.f64("__seed", 0.0) as u64 + 1;
|
||||
let seed = next_seed(d);
|
||||
d.fields.insert("__seed".into(), json!(seed));
|
||||
let p = json!({"map": rows, "order": !sb, "saturationBrightness": sb, "seed": seed});
|
||||
match app.session.execute("recolor.randomize", &p) {
|
||||
@@ -897,6 +902,7 @@ fn sliders(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]
|
||||
fn merge(rows: &mut Vec<Value>, sel: &[usize]) {
|
||||
let mut sel: Vec<usize> = sel.iter().copied().filter(|i| *i < rows.len()).collect();
|
||||
sel.sort_unstable();
|
||||
sel.dedup();
|
||||
let Some((&first, rest)) = sel.split_first() else { return };
|
||||
let mut moved = vec![];
|
||||
for &i in rest.iter().rev() {
|
||||
@@ -1208,6 +1214,25 @@ mod tests {
|
||||
assert_eq!(r[2]["to"], "rgb 0 0 128");
|
||||
}
|
||||
|
||||
/// A selection that lists a row twice merges it once, as `ui.dialog.set` can store one.
|
||||
#[test]
|
||||
fn merge_takes_a_repeated_row_once() {
|
||||
let mut r = rows();
|
||||
merge(&mut r, &[0, 2, 2]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[0]["from"], json!(["rgb 255 0 0", "rgb 0 255 0"]));
|
||||
merge(&mut r, &[1, 1]);
|
||||
assert_eq!(r.len(), 2);
|
||||
}
|
||||
|
||||
/// Randomize after a seed past `u64::MAX` keeps the largest seed.
|
||||
#[test]
|
||||
fn next_seed_holds_at_the_largest() {
|
||||
assert_eq!(next_seed(&Dialog::new("recolor", json!({}))), 1);
|
||||
assert_eq!(next_seed(&Dialog::new("recolor", json!({"__seed": 4}))), 5);
|
||||
assert_eq!(next_seed(&Dialog::new("recolor", json!({"__seed": 1e30}))), u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_handles_move_together() {
|
||||
let mut r = rows();
|
||||
|
||||
@@ -160,6 +160,23 @@ mod tests {
|
||||
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
|
||||
}
|
||||
|
||||
/// An export whose encoder notes an option it didn't apply (lossy WebP) before what the file
|
||||
/// loses (type in a font that isn't installed): the status bar names what the file loses, and
|
||||
/// the hover lists it first.
|
||||
#[test]
|
||||
fn an_export_names_what_the_file_loses_before_a_note_on_the_options() {
|
||||
let (mut app, _) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
let r = export(&mut app, Some("webp"), Some("/out/d.webp".into()), &json!({"lossless": false})).unwrap();
|
||||
let notes = notes_of(&r);
|
||||
assert_eq!(notes.len(), 2, "{r}");
|
||||
assert!(notes[0].contains("Nonexistent Sans Probe"), "{r}");
|
||||
assert!(notes[1].starts_with("lossy WebP"), "{r}");
|
||||
assert_eq!(app.ui.status, exported_with_notes("/out/d.webp", 2, ¬es[0]));
|
||||
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
|
||||
}
|
||||
|
||||
/// #1685: a save or an export that can't write its file says so in a dialog that names the
|
||||
/// file and the reason and stays until it is closed; the document stays modified and keeps
|
||||
/// the file it had. An agent's request only gets the error, and a message that arrives while
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
|
||||
return Ok(json!({ "replaces": replaced }));
|
||||
}
|
||||
let files = write_encoded(write, &doc, &path, &enc)?;
|
||||
let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings });
|
||||
let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.notes() });
|
||||
if files.len() > 1 {
|
||||
out["files"] = json!(files);
|
||||
}
|
||||
|
||||
@@ -84,11 +84,10 @@ fn append_indent_targets(node: &Node, range: std::ops::Range<usize>, targets: &m
|
||||
/// one frame ends and the next begins, the last paragraph of the one and the first of the next are
|
||||
/// pieces of one story paragraph: a paragraph that runs on into the next frame, or an empty piece
|
||||
/// after a frame's last paragraph break. Such a paragraph is targeted once, by its last piece with
|
||||
/// text in a frame that `selected` takes, the piece whose style the story keeps when it flows again.
|
||||
/// A paragraph whose text lies only in frames that `selected` leaves out is not targeted. An empty
|
||||
/// paragraph takes its style from its last piece when the story flows again, so it is targeted by
|
||||
/// that piece when `selected` takes its frame, and otherwise not.
|
||||
fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(NodeId) -> bool, targets: &mut Vec<IndentTarget>) {
|
||||
/// text, the piece whose style the story keeps when it flows again. An empty paragraph takes its
|
||||
/// style from its last piece when the story flows again, so it is targeted by that piece. Locked and
|
||||
/// hidden frames are targeted like the others.
|
||||
fn append_story_targets(doc: &Document, story: &[NodeId], targets: &mut Vec<IndentTarget>) {
|
||||
/// The part of a story paragraph in one frame: paragraph `index` there, starting at byte `start`.
|
||||
struct Piece<'a> {
|
||||
id: NodeId,
|
||||
@@ -113,11 +112,7 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node
|
||||
}
|
||||
}
|
||||
for pieces in ¶graphs {
|
||||
let pick = if pieces.iter().any(|p| p.has_text) {
|
||||
pieces.iter().rev().find(|p| p.has_text && selected(p.id))
|
||||
} else {
|
||||
pieces.last().filter(|p| selected(p.id))
|
||||
};
|
||||
let pick = pieces.iter().rev().find(|p| p.has_text).or_else(|| pieces.last());
|
||||
if let Some(p) = pick {
|
||||
targets.push(indent_target(p.id, p.text, p.index, p.start));
|
||||
}
|
||||
@@ -125,13 +120,14 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node
|
||||
}
|
||||
|
||||
/// Paragraphs touched by the Type selection (every paragraph of the story after Select All in
|
||||
/// threaded text), or all paragraphs of selected text objects. Each paragraph of a threaded story
|
||||
/// is listed once, in story order ([`append_story_targets`]).
|
||||
/// threaded text), or all paragraphs of selected text objects. A selected frame of a threaded story
|
||||
/// selects the whole story, its locked and hidden frames included. Each paragraph of a threaded
|
||||
/// story is listed once, in story order ([`append_story_targets`]).
|
||||
fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec<IndentTarget> {
|
||||
let mut targets = Vec::new();
|
||||
if let Some((_, a, b)) = super::character::text_editing(app) {
|
||||
if let (Some(story), Some(st)) = (super::character::text_story(app), app.session.active()) {
|
||||
append_story_targets(&st.doc, &story, |_| true, &mut targets);
|
||||
append_story_targets(&st.doc, &story, &mut targets);
|
||||
} else if let NodeKind::Text(text) = &node.kind {
|
||||
append_indent_targets(node, text.paragraphs_in(a, b), &mut targets);
|
||||
}
|
||||
@@ -153,7 +149,7 @@ fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec<IndentTarget> {
|
||||
}
|
||||
let story = st.doc.story_of(id);
|
||||
done.extend(story.iter().copied());
|
||||
append_story_targets(&st.doc, &story, |f| taken.contains(&f), &mut targets);
|
||||
append_story_targets(&st.doc, &story, &mut targets);
|
||||
}
|
||||
}
|
||||
targets
|
||||
@@ -243,9 +239,9 @@ fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: Ma
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an absolute ruler position through paragraph-format commands. Paragraphs that need values
|
||||
/// of their own get one command each, the story's last paragraph first: threaded text re-flows
|
||||
/// after each command, and a re-flow moves only text after the paragraph that changed.
|
||||
/// Apply an absolute ruler position through paragraph-format commands. When the paragraphs need
|
||||
/// values of their own, one command sets each paragraph's ([`set_each_indent`]), and threaded text
|
||||
/// flows again once.
|
||||
fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) {
|
||||
if targets.is_empty() || !target.is_finite() {
|
||||
return;
|
||||
@@ -253,23 +249,10 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para:
|
||||
let Some(target) = bounded_marker_position(marker, target, span, targets, both) else { return };
|
||||
if marker == IndentMarker::Upper {
|
||||
if targets.iter().all(|p| p.left == para.left_indent) {
|
||||
if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"firstLineIndent": target - para.left_indent})) {
|
||||
app.status(e);
|
||||
}
|
||||
run_on_targets(app, targets, json!({"firstLineIndent": target - para.left_indent}));
|
||||
} else {
|
||||
super::character::end_typing(app);
|
||||
let commands: Vec<Value> = targets
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|p| {
|
||||
json!({"command": "text.setFormat", "params": {
|
||||
"ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left
|
||||
}})
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = app.run("command.batch", json!({"label": tl!("First-line Left Indent"), "commands": commands})) {
|
||||
app.status(e);
|
||||
}
|
||||
let changes = targets.iter().map(|p| (p, None, Some(target - p.left))).collect();
|
||||
set_each_indent(app, tl!("First-line Left Indent"), changes);
|
||||
}
|
||||
} else {
|
||||
// The ruler shows each paragraph's left indent with its list level's added; the paragraph
|
||||
@@ -284,26 +267,58 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para:
|
||||
_ => None,
|
||||
};
|
||||
if let Some(params) = params {
|
||||
if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) {
|
||||
app.status(e);
|
||||
}
|
||||
run_on_targets(app, targets, params);
|
||||
return;
|
||||
}
|
||||
super::character::end_typing(app);
|
||||
let commands: Vec<Value> = targets
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|p| {
|
||||
let mut params = json!({"ids": [p.id], "start": p.start, "end": p.start, "leftIndent": target - p.level});
|
||||
if !both {
|
||||
params["firstLineIndent"] = json!(p.left + p.first_line - target);
|
||||
}
|
||||
json!({"command": "text.setFormat", "params": params})
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) {
|
||||
app.status(e);
|
||||
}
|
||||
let changes = targets.iter().map(|p| (p, Some(target - p.level), (!both).then_some(p.left + p.first_line - target))).collect();
|
||||
set_each_indent(app, tl!("Left Indent"), changes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one `text.setFormat` with `params` on the paragraphs the Paragraph panel acts on while typing,
|
||||
/// and with the Selection tool on every type object of `targets`, so a selected frame of a threaded
|
||||
/// story sets the whole story ([`indent_targets`]).
|
||||
fn run_on_targets(app: &mut VectorcraftApp, targets: &[IndentTarget], mut params: Value) {
|
||||
if super::character::text_editing(app).is_none() {
|
||||
let mut seen = HashSet::new();
|
||||
params["ids"] = json!(targets.iter().map(|p| p.id).filter(|id| seen.insert(*id)).collect::<Vec<_>>());
|
||||
}
|
||||
if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Give each paragraph its own left and first-line indents (None: as it is) in one `text.setFormat`
|
||||
/// with `paragraphIndents`, one undo step named `label`. Paragraphs that keep their indents are
|
||||
/// left out, and when every paragraph keeps them nothing is run.
|
||||
fn set_each_indent(app: &mut VectorcraftApp, label: &str, changes: Vec<(&IndentTarget, Option<f64>, Option<f64>)>) {
|
||||
// As `text.setFormat` keeps a paragraph's indents, within 1296 pt either way.
|
||||
let bound = |v: Option<f64>| v.map(|v| v.clamp(-1296.0, 1296.0));
|
||||
let entries: Vec<Value> = changes
|
||||
.into_iter()
|
||||
.filter_map(|(p, left, first_line)| {
|
||||
let (left, first_line) = (bound(left), bound(first_line));
|
||||
let own_left = p.left - p.level;
|
||||
if left.is_none_or(|v| v == own_left) && first_line.is_none_or(|v| v == p.first_line) {
|
||||
return None;
|
||||
}
|
||||
let mut entry = json!({"id": p.id, "start": p.start});
|
||||
if let Some(v) = left {
|
||||
entry["leftIndent"] = json!(v);
|
||||
}
|
||||
if let Some(v) = first_line {
|
||||
entry["firstLineIndent"] = json!(v);
|
||||
}
|
||||
Some(entry)
|
||||
})
|
||||
.collect();
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
super::character::end_typing(app);
|
||||
let command = json!({"command": "text.setFormat", "params": {"paragraphIndents": entries}});
|
||||
if let Err(e) = app.run("command.batch", json!({"label": label, "commands": [command]})) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -617,6 +617,52 @@ fn indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_p
|
||||
}
|
||||
}
|
||||
|
||||
/// A marker drag over many paragraphs of a threaded story with mixed indents sends one
|
||||
/// `text.setFormat` for all of them, in one undo step, and a drag that changes no paragraph runs
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn indent_markers_over_many_threaded_paragraphs_send_one_format_command() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 900, "height": 3000})).unwrap();
|
||||
let story = (1..=128).map(|i| format!("p{i}")).collect::<Vec<_>>().join("\n");
|
||||
let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 100}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 2900]})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("text.setFormat", json!({"ids": [b], "leftIndent": 10})).unwrap();
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
let before = story_paragraphs(&app, &[a, b]);
|
||||
assert_eq!(before.len(), 128, "{before:?}");
|
||||
assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}");
|
||||
let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len());
|
||||
let drag = |app: &mut VectorcraftApp, marker, to| {
|
||||
let (node, para, span) = current_para(app).unwrap();
|
||||
let targets = indent_targets(app, &node);
|
||||
assert_eq!(targets.len(), 128);
|
||||
set_marker(app, marker, to, ¶, span, &targets, false);
|
||||
};
|
||||
let (undo, journal) = steps(&app);
|
||||
drag(&mut app, IndentMarker::Upper, 60.0);
|
||||
assert_eq!(steps(&app), (undo + 1, journal + 1));
|
||||
let (name, params) = app.session.journal.last().cloned().unwrap();
|
||||
assert_eq!(name, "command.batch");
|
||||
let commands = params["commands"].as_array().unwrap();
|
||||
assert_eq!(commands.len(), 1, "one command for every paragraph");
|
||||
assert_eq!(commands[0]["command"], "text.setFormat");
|
||||
assert_eq!(commands[0]["params"]["paragraphIndents"].as_array().unwrap().len(), 128);
|
||||
let after = story_paragraphs(&app, &[a, b]);
|
||||
for (was, now) in before.iter().zip(&after) {
|
||||
assert_eq!((&now.0, now.1, now.2), (&was.0, was.1, 60.0), "{after:?}");
|
||||
}
|
||||
// The same position again changes no paragraph.
|
||||
drag(&mut app, IndentMarker::Upper, 60.0);
|
||||
assert_eq!(steps(&app), (undo + 1, journal + 1), "nothing ran");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(story_paragraphs(&app, &[a, b]), before);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(story_paragraphs(&app, &[a, b]), after);
|
||||
}
|
||||
|
||||
/// After Select All in threaded text, a paragraph split between two frames is one target.
|
||||
#[test]
|
||||
fn indent_markers_after_select_all_list_a_split_paragraph_once() {
|
||||
@@ -633,12 +679,12 @@ fn indent_markers_after_select_all_list_a_split_paragraph_once() {
|
||||
assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}");
|
||||
}
|
||||
|
||||
/// With the Selection tool on some frames of a threaded story, only the paragraphs with text in
|
||||
/// those frames are listed, each once: a selected frame that starts inside a paragraph lists that
|
||||
/// paragraph, and so does a selected frame that ends inside one. Text in a selected group is
|
||||
/// listed with the story's frames, and an empty text object keeps its one paragraph.
|
||||
/// With the Selection tool on any frame of a threaded story, the indent markers list every
|
||||
/// paragraph of the story, each once in story order: a selected story is one text. Text in a
|
||||
/// selected group is listed with the story's frames, and an empty text object keeps its one
|
||||
/// paragraph.
|
||||
#[test]
|
||||
fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() {
|
||||
fn indent_markers_on_some_threaded_frames_list_the_whole_story_once() {
|
||||
let (mut app, [a, b, c]) = split_story_app();
|
||||
let count = |app: &VectorcraftApp, id: u64| text_of(app, id).paragraph_count();
|
||||
let targets_of = |app: &mut VectorcraftApp, ids: Vec<u64>| {
|
||||
@@ -646,29 +692,64 @@ fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() {
|
||||
let (node, _, _) = current_para(app).unwrap();
|
||||
story_positions(&indent_targets(app, &node), &[a, b, c])
|
||||
};
|
||||
// The continuing frame alone: its first paragraph is the one the frame before ends inside.
|
||||
let only_b = targets_of(&mut app, vec![b]);
|
||||
assert_eq!(only_b.len(), count(&app, b), "{only_b:?}");
|
||||
assert_eq!(only_b.first(), Some(&(1, 0)), "{only_b:?}");
|
||||
let only_a = targets_of(&mut app, vec![a]);
|
||||
assert_eq!(only_a.len(), count(&app, a), "{only_a:?}");
|
||||
// Two frames that share a paragraph list it once, by the later frame's piece.
|
||||
let both = targets_of(&mut app, vec![c, a, b]);
|
||||
assert_eq!(both.len(), count(&app, a) + count(&app, b) + count(&app, c) - 2, "{both:?}");
|
||||
assert!(both.windows(2).all(|w| w[0] < w[1]), "{both:?}");
|
||||
// The first and last frames of the story, and a grouped empty text object besides.
|
||||
let story = count(&app, a) + count(&app, b) + count(&app, c) - 2;
|
||||
let all = targets_of(&mut app, vec![c, a, b]);
|
||||
assert_eq!(all.len(), story, "two frames that share a paragraph list it once: {all:?}");
|
||||
assert!(all.windows(2).all(|w| w[0] < w[1]), "{all:?}");
|
||||
for frame in [a, b, c] {
|
||||
assert_eq!(targets_of(&mut app, vec![frame]), all, "frame {frame} alone lists the whole story");
|
||||
}
|
||||
// A grouped empty text object besides the last frame of the story.
|
||||
let empty = app.run("text.create", json!({"x": 10, "y": 900, "text": "", "area": {"width": 100, "height": 40}})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [empty, c]})).unwrap();
|
||||
let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [group, a]})).unwrap();
|
||||
let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().clone();
|
||||
app.run("select.set", json!({"ids": [group]})).unwrap();
|
||||
let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(c)).unwrap().clone();
|
||||
let targets = indent_targets(&app, &node);
|
||||
let ids: Vec<u64> = targets.iter().map(|p| p.id).collect();
|
||||
assert_eq!(ids.iter().filter(|id| **id == empty).count(), 1, "{ids:?}");
|
||||
let story: Vec<IndentTarget> = targets.into_iter().filter(|p| p.id != empty).collect();
|
||||
let positions = story_positions(&story, &[a, b, c]);
|
||||
assert_eq!(positions.len(), count(&app, a) + count(&app, c), "{positions:?}");
|
||||
assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}");
|
||||
let in_story: Vec<IndentTarget> = targets.into_iter().filter(|p| p.id != empty).collect();
|
||||
assert_eq!(story_positions(&in_story, &[a, b, c]), all);
|
||||
}
|
||||
|
||||
/// A marker drag on one selected frame of a threaded story sets every paragraph of the story, in
|
||||
/// its locked and hidden frames too, in one undo step, whether the paragraphs share their indents
|
||||
/// or have their own.
|
||||
#[test]
|
||||
fn indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames() {
|
||||
let (mut app, [a, b, c]) = split_story_app();
|
||||
app.run("select.set", json!({"ids": [b]})).unwrap();
|
||||
app.run("object.lock", json!({})).unwrap();
|
||||
app.run("select.set", json!({"ids": [c]})).unwrap();
|
||||
app.run("object.hide", json!({})).unwrap();
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
// Every paragraph has the same indents.
|
||||
let (node, para, span) = current_para(&app).unwrap();
|
||||
let targets = indent_targets(&app, &node);
|
||||
assert_eq!(targets.len(), 12);
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
set_marker(&mut app, IndentMarker::Upper, 30.0, ¶, span, &targets, false);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
|
||||
let shared = story_paragraphs(&app, &[a, b, c]);
|
||||
assert_eq!(shared.len(), 12, "{shared:?}");
|
||||
assert!(shared.iter().all(|p| (p.1, p.2) == (0.0, 30.0)), "{shared:?}");
|
||||
// The paragraphs of the hidden frame have a left indent of their own.
|
||||
app.run("text.setFormat", json!({"ids": [c], "leftIndent": 10})).unwrap();
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
let before = story_paragraphs(&app, &[a, b, c]);
|
||||
assert_eq!(before.len(), 12, "{before:?}");
|
||||
assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}");
|
||||
let (node, para, span) = current_para(&app).unwrap();
|
||||
let targets = indent_targets(&app, &node);
|
||||
assert_eq!(targets.len(), 12);
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
set_marker(&mut app, IndentMarker::Upper, 60.0, ¶, span, &targets, false);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
|
||||
let after = story_paragraphs(&app, &[a, b, c]);
|
||||
assert_eq!(after.iter().map(|p| &p.0).collect::<Vec<_>>(), before.iter().map(|p| &p.0).collect::<Vec<_>>());
|
||||
for (was, now) in before.iter().zip(&after) {
|
||||
assert_eq!((now.1, now.2), (was.1, 60.0), "{} keeps its left indent and starts at 60 pt: {after:?}", now.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -401,6 +401,48 @@ fn a_mixed_revolve_cap_shows_a_dash() {
|
||||
assert_eq!(undo_len(&app), history);
|
||||
}
|
||||
|
||||
/// Quick Actions › Expand as Wireframes shows for every object type, and turns the selected 3D
|
||||
/// object into a group of stroked edges in one undo step.
|
||||
#[test]
|
||||
fn expand_as_wireframes_replaces_the_object_with_its_edges() {
|
||||
for kind in ["extrude", "inflate", "revolve", "plane"] {
|
||||
let mut app = app();
|
||||
set_type(&mut app, kind).unwrap();
|
||||
let text = painted_text(&mut app, show);
|
||||
assert!(text.contains("Quick Actions") && text.contains("Expand as Wireframes"), "{kind}: {text}");
|
||||
let id = app.session.active().unwrap().selection.objects[0];
|
||||
let history = undo_len(&app);
|
||||
super::expand_as_wireframes(&mut app).unwrap();
|
||||
assert_eq!(undo_len(&app), history + 1, "{kind}");
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(matches!(st.doc.node(id).unwrap().kind, vectorcraft_doc::NodeKind::Group { .. }), "{kind}");
|
||||
assert_eq!(st.selection.objects, vec![id], "{kind}");
|
||||
// The group has no 3D effect: the panel offers the object types again.
|
||||
assert!(target(&app).is_none(), "{kind}");
|
||||
}
|
||||
// Without a 3D effect in the selection, nothing changes.
|
||||
let mut app = app();
|
||||
let history = undo_len(&app);
|
||||
assert!(super::expand_as_wireframes(&mut app).is_err());
|
||||
assert_eq!(undo_len(&app), history);
|
||||
}
|
||||
|
||||
/// The Quick Actions show on the Object tab of the effects that have one (Revolve (Classic)
|
||||
/// included) and not on their Rotation tab; Revolve and Rotate show them on their first tab.
|
||||
#[test]
|
||||
fn quick_actions_show_on_the_object_tab_only() {
|
||||
use crate::state::Dialog;
|
||||
use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, REVOLVE};
|
||||
for effect in [EXTRUDE, INFLATE, CLASSIC_EXTRUDE, CLASSIC_REVOLVE] {
|
||||
for (tab, shown) in [("object", true), ("rotation", false)] {
|
||||
let d = Dialog::new("x", json!({"__effect": effect, "__tab": tab, "__gizmo": "rotation"}));
|
||||
assert_eq!(super::quick_actions_shown(&d), shown, "{effect} {tab}");
|
||||
}
|
||||
}
|
||||
let d = Dialog::new("x", json!({"__effect": REVOLVE, "__tab": "rotation", "__gizmo": "rotation"}));
|
||||
assert!(super::quick_actions_shown(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_settings_lists_only_new_values() {
|
||||
let before = json!({"depth": 50.0, "cap": true, "bevel": "none"});
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
use egui::Ui;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Effect, Node};
|
||||
use vectorcraft_effects::{THREE_D_TYPES, is_three_d};
|
||||
use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, THREE_D_TYPES, is_three_d};
|
||||
|
||||
use super::{live_run, pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, Live, dim_label};
|
||||
|
||||
/// The panel's id (`window.panel`).
|
||||
@@ -242,6 +243,32 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let changed = changed_settings(&before, &after);
|
||||
let dragging = ctx.input(|i| i.pointer.any_down());
|
||||
send(app, &ctx, changed, dragging);
|
||||
if quick_actions_shown(&d) {
|
||||
ui.add_space(10.0);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(tl!("Quick Actions")).font(crate::theme::semibold(13.0)).color(t.text));
|
||||
let tip = tl!("Replace the selected 3D objects with the edges of their faces, as stroked paths.");
|
||||
if ui.button(tl!("Expand as Wireframes")).on_hover_text(tip).clicked() {
|
||||
finish(app, &ctx);
|
||||
if let Err(e) = expand_as_wireframes(app) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the panel show its Quick Actions? On the Object tab of the effects that have one, and on
|
||||
/// the first tab of the others (Revolve, Rotate), never on Lighting.
|
||||
fn quick_actions_shown(d: &Dialog) -> bool {
|
||||
let effect = d.str("__effect");
|
||||
let object_tab = [EXTRUDE, INFLATE, CLASSIC_EXTRUDE, CLASSIC_REVOLVE].contains(&effect.as_str());
|
||||
!crate::dialogs::revolve_gizmo::lighting(d) && (!object_tab || d.str("__tab") != "rotation")
|
||||
}
|
||||
|
||||
/// Quick Actions › Expand as Wireframes: each selected object with a 3D effect becomes a group of
|
||||
/// the edges of its faces (`effect.expandAsWireframes`), one undo step.
|
||||
pub(crate) fn expand_as_wireframes(app: &mut VectorcraftApp) -> Result<(), String> {
|
||||
app.run("effect.expandAsWireframes", json!({})).map(|_| ())
|
||||
}
|
||||
|
||||
/// Sets to null each setting in `fields` (the first selected object's 3D effect) that the 3D effect
|
||||
|
||||
@@ -587,7 +587,8 @@ fn a_deferred_swatch_paste_rejects_a_changed_token_or_closed_document() {
|
||||
&crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "__deferredPaste", "value": 999999})).0,
|
||||
);
|
||||
let crate::control::Outcome::Done(result) = result else { panic!("expected dialog result") };
|
||||
assert_eq!(result["ok"], true, "{result}");
|
||||
assert_eq!(result["error"], "the `swatchConflict` dialog cannot set protected field `__deferredPaste`", "{result}");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("__deferredPaste".into(), json!(999999));
|
||||
}
|
||||
let error = crate::dialogs::confirm(&mut app).unwrap_err();
|
||||
assert!(
|
||||
|
||||
@@ -10,6 +10,7 @@ use vectorcraft_engine::file_access::AutomationRoots;
|
||||
|
||||
use crate::background::SharedWriteFn;
|
||||
use crate::control::ControlRequest;
|
||||
use crate::state::Dialog;
|
||||
use crate::{Services, VectorcraftApp};
|
||||
|
||||
/// A folder holding `in` (the read root, with `a.svg`), `out` (the write root) and `outside`
|
||||
@@ -1256,12 +1257,12 @@ fn automation_cannot_approve_a_plugin_install() {
|
||||
assert!(app.ui.dialog.is_some(), "the refused answer leaves it open");
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||||
|
||||
// What the dialog installs isn't in its fields.
|
||||
// What the dialog installs isn't in its fields, and its `__` fields can't be set.
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__params", "value": {"dataBase64": "AAAA"}}));
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
refused(&r, "cannot set protected field `__params`");
|
||||
// What MCP's `answer_dialog` sends: `ui.dialog.set {fields}`, then `ui.dialog.confirm`.
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]}));
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
refused(&r, "cannot set protected field `__params`");
|
||||
let r = request(&mut app, &tx, "ui.dialog.confirm", json!({}));
|
||||
refused(&r, ASKED);
|
||||
assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none());
|
||||
@@ -1289,6 +1290,231 @@ fn automation_cannot_approve_a_plugin_install() {
|
||||
app.run("plugin.remove", json!({"id": id})).unwrap();
|
||||
}
|
||||
|
||||
/// An app with a control channel and a new document.
|
||||
fn controlled() -> (VectorcraftApp, std::sync::mpsc::Sender<ControlRequest>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx);
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
(app, tx)
|
||||
}
|
||||
|
||||
/// The fields of the open dialog.
|
||||
fn dialog_fields(app: &VectorcraftApp) -> Option<serde_json::Map<String, Value>> {
|
||||
app.ui.dialog.as_ref().map(|d| d.fields.clone())
|
||||
}
|
||||
|
||||
/// The objects on the document's first layer.
|
||||
fn objects(app: &VectorcraftApp) -> usize {
|
||||
app.session.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len())
|
||||
}
|
||||
|
||||
/// The left edge and width of the first object on the document's first layer.
|
||||
fn first_object(app: &VectorcraftApp) -> (f64, f64) {
|
||||
let b = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
(b.x0, b.width())
|
||||
}
|
||||
|
||||
/// Each of `sets` sent as `ui.dialog.set` is refused with the protected-field error of the
|
||||
/// `kind` dialog, sets no field, and runs nothing.
|
||||
fn refused_sets(app: &mut VectorcraftApp, tx: &std::sync::mpsc::Sender<ControlRequest>, kind: &str, sets: &[Value]) {
|
||||
for set in sets {
|
||||
let before = dialog_fields(app);
|
||||
let r = request(app, tx, "ui.dialog.set", set.clone());
|
||||
let says = format!("the `{kind}` dialog cannot set protected field `__");
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.starts_with(&says)), "{set}: {r}");
|
||||
assert_eq!(dialog_fields(app), before, "{set}: no field changes and the dialog stays open");
|
||||
assert_eq!(objects(app), 0, "{set}: nothing ran");
|
||||
}
|
||||
}
|
||||
|
||||
/// A confirmation a person opened runs the command it was opened with: `ui.dialog.set` can't
|
||||
/// change its `__command` or `__params`, alone or in a list with other fields and `action:
|
||||
/// "confirm"`, and OK then runs the command it asked about.
|
||||
#[test]
|
||||
fn dialog_set_cannot_redirect_person_opened_confirm() {
|
||||
let (mut app, tx) = controlled();
|
||||
let rect = json!({"x": 10, "y": 10, "width": 20, "height": 20});
|
||||
crate::dialogs::confirm::ask(&mut app, "Add a rectangle?", "", "shape.rectangle", rect);
|
||||
let other = json!({"x": 0, "y": 0, "width": 90, "height": 90});
|
||||
refused_sets(
|
||||
&mut app,
|
||||
&tx,
|
||||
crate::dialogs::confirm::KIND,
|
||||
&[
|
||||
json!({"field": "__command", "value": "file.close"}),
|
||||
json!({"field": "__params", "value": other}),
|
||||
json!({"fields": [{"field": "detail", "value": "Sure?"}, {"field": "__command", "value": "file.close"}], "action": "confirm"}),
|
||||
json!({"fields": [{"field": "__params", "value": other}, {"field": "detail", "value": "Sure?"}], "action": "confirm"}),
|
||||
],
|
||||
);
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "detail", "value": "Sure?"}], "action": "confirm"}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(app.ui.dialog.is_none() && objects(&app) == 1, "OK ran the command it asked about");
|
||||
assert_eq!(first_object(&app), (10.0, 20.0));
|
||||
}
|
||||
|
||||
/// The generic parameter dialog runs its command with its fixed parameters: `ui.dialog.set` can
|
||||
/// change the parameters it shows, not `__command`, `__params` or `__label`.
|
||||
#[test]
|
||||
fn dialog_set_cannot_redirect_parameter_dialog() {
|
||||
let (mut app, tx) = controlled();
|
||||
let fields = json!({"__command": "shape.rectangle", "__label": "Rectangle", "__params": {"x": 10, "y": 10}, "width": 20, "height": 20});
|
||||
app.ui.dialog = Some(Dialog::new("command", fields));
|
||||
refused_sets(
|
||||
&mut app,
|
||||
&tx,
|
||||
"command",
|
||||
&[
|
||||
json!({"field": "__command", "value": "file.close"}),
|
||||
json!({"field": "__params", "value": {"x": 500, "y": 500}}),
|
||||
json!({"field": "__label", "value": "Close"}),
|
||||
json!({"fields": [{"field": "width", "value": 30}, {"field": "__command", "value": "file.close"}], "action": "confirm"}),
|
||||
],
|
||||
);
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "width", "value": 30, "action": "confirm"}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(app.ui.dialog.is_none() && objects(&app) == 1, "OK ran the dialog's command");
|
||||
assert_eq!(first_object(&app), (10.0, 30.0), "with its fixed x and the width set");
|
||||
}
|
||||
|
||||
/// A plug-in command's dialog runs that command of that plug-in: `ui.dialog.set` can't change its
|
||||
/// `__command` or `__plugin`.
|
||||
#[test]
|
||||
fn dialog_set_cannot_redirect_plugin_command() {
|
||||
let (mut app, tx) = controlled();
|
||||
let fields = json!({"__plugin": "test.ui.redirect", "__command": "plugin.test.ui.redirect.a", "__label": "A", "amount": 1});
|
||||
app.ui.dialog = Some(Dialog::new(crate::dialogs::plugin::KIND, fields));
|
||||
refused_sets(
|
||||
&mut app,
|
||||
&tx,
|
||||
crate::dialogs::plugin::KIND,
|
||||
&[
|
||||
json!({"field": "__command", "value": "plugin.test.ui.redirect.b"}),
|
||||
json!({"field": "__plugin", "value": "test.ui.other"}),
|
||||
json!({"fields": [{"field": "amount", "value": 2}, {"field": "__command", "value": ""}], "action": "confirm"}),
|
||||
],
|
||||
);
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "amount", "value": 2}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
|
||||
/// A `__` field the dialog does not hold is refused as a protected field too, and so is a control
|
||||
/// of another kind of dialog; the error for an unknown field lists the dialog's own controls only.
|
||||
#[test]
|
||||
fn dialog_set_rejects_unknown_hidden_field() {
|
||||
let (mut app, tx) = controlled();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
crate::menus::invoke(&mut app, "object.transformEach", json!({}));
|
||||
for field in ["__anything", "__section", "__ratio", "__constrainApplied"] {
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": field, "value": true}));
|
||||
assert_eq!(r["error"], format!("the `transformEach` dialog cannot set protected field `{field}`"), "{r}");
|
||||
assert!(!dialog_fields(&app).unwrap().contains_key("__anything"));
|
||||
}
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "nosuch", "value": true}));
|
||||
let error = r["error"].as_str().unwrap_or_default();
|
||||
assert!(error.contains("has no field `nosuch`") && error.contains("__constrain"), "{r}");
|
||||
assert!(!error.contains("__ratio") && !error.contains("__section"), "{r}");
|
||||
|
||||
// Artboard Options' Fit, size preset and reference point flags are set by their buttons
|
||||
// together with the size and position they record.
|
||||
app.ui.dialog = Some(Dialog::new("artboardOptions", json!({"width": 60, "height": 20})));
|
||||
for (field, value) in [("__fit", json!(true)), ("__sizeChoice", json!({"landscape": true})), ("__refx", json!(2)), ("__refy", json!(2))] {
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": field, "value": value}));
|
||||
assert_eq!(r["error"], format!("the `artboardOptions` dialog cannot set protected field `{field}`"), "{r}");
|
||||
assert!(!dialog_fields(&app).unwrap().contains_key(field));
|
||||
}
|
||||
// Scribble's Settings menu sets the preset's parameters along with its name.
|
||||
app.ui.dialog = Some(Dialog::new("effect", json!({"__effect": "scribble", "angle": 30})));
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__scribbleSetting", "value": "sharp"}));
|
||||
assert_eq!(r["error"], "the `effect` dialog cannot set protected field `__scribbleSetting`", "{r}");
|
||||
}
|
||||
|
||||
/// The dialog controls held under a `__` name are still set: Transform Each's link, the Save PDF
|
||||
/// section and its All artboards checkbox, and the Preferences search.
|
||||
#[test]
|
||||
fn dialog_set_allows_hidden_controls() {
|
||||
let (mut app, tx) = controlled();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
crate::menus::invoke(&mut app, "object.transformEach", json!({}));
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__constrain", "value": true}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("__constrain")));
|
||||
app.ui.dialog = None;
|
||||
|
||||
app.run("ui.savePdfDialog", json!({})).unwrap();
|
||||
let set = json!({"fields": [{"field": "__section", "value": "Security"}, {"field": "__allArtboards", "value": false}]});
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", set);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.str("__section"), d.bool("__allArtboards")), ("Security".to_string(), false));
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__save", "value": "file.saveCopy"}));
|
||||
assert_eq!(r["error"], "the `savePdf` dialog cannot set protected field `__save`", "{r}");
|
||||
app.ui.dialog = None;
|
||||
|
||||
// The raster options dialogs share the Resolution menu's Other, PSD Options included.
|
||||
for kind in ["pngOptions", "tiffOptions", "psdOptions"] {
|
||||
app.ui.dialog = Some(Dialog::new(kind, json!({"ppi": 72})));
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__otherPpi", "value": true}));
|
||||
assert_eq!(r["ok"], true, "{kind}: {r}");
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
|
||||
// A style section's reset flag alone would keep the section's values: scripts run the Reset
|
||||
// Panel commands, which clear them. Which of the two styles a new style is stays fixed.
|
||||
for (kind, para, flag) in [
|
||||
("charStyleOptions", false, "__resetColor"),
|
||||
("paraStyleOptions", true, "__resetHyphenation"),
|
||||
("newTextStyle", false, "__resetColor"),
|
||||
("newTextStyle", true, "__resetHyphenation"),
|
||||
] {
|
||||
app.ui.dialog = Some(Dialog::new(kind, json!({"name": "A", "__para": para})));
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": flag, "value": true}));
|
||||
assert_eq!(r["error"], format!("the `{kind}` dialog cannot set protected field `{flag}`"), "{r}");
|
||||
assert!(!app.ui.dialog.as_ref().is_some_and(|d| d.bool(flag)), "{kind}");
|
||||
}
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__para", "value": false}));
|
||||
assert_eq!(r["error"], "the `newTextStyle` dialog cannot set protected field `__para`", "{r}");
|
||||
app.ui.dialog = None;
|
||||
|
||||
crate::prefs_dialog::open(&mut app, None);
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__search", "value": "grid"}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("__search")), Some("grid".to_string()));
|
||||
}
|
||||
|
||||
/// Save PDF's and Print's OK save a preset while Save Preset… asks for its name: `ui.dialog.set`
|
||||
/// can't add `__savePresetAs` to turn OK into saving a preset, alone or with `action: "confirm"`,
|
||||
/// and can type the name once Save Preset… has added the field.
|
||||
#[test]
|
||||
fn dialog_set_cannot_open_save_preset() {
|
||||
let (mut app, tx) = controlled();
|
||||
let saved = |app: &VectorcraftApp| (app.session.prefs.pdf_presets.len(), app.session.prefs.print_presets.len());
|
||||
for (open, kind) in [("ui.savePdfDialog", "savePdf"), ("file.print", "print")] {
|
||||
app.run(open, json!({})).unwrap();
|
||||
let presets = saved(&app);
|
||||
for set in [
|
||||
json!({"field": "__savePresetAs", "value": "Proofs"}),
|
||||
json!({"fields": [{"field": "__savePresetAs", "value": "Proofs"}], "action": "confirm"}),
|
||||
] {
|
||||
let before = dialog_fields(&app);
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", set.clone());
|
||||
assert_eq!(r["error"], format!("the `{kind}` dialog cannot set protected field `__savePresetAs`"), "{set}: {r}");
|
||||
assert_eq!(dialog_fields(&app), before, "{set}: no field changes and the dialog stays open");
|
||||
}
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "nosuch", "value": true}));
|
||||
assert!(!r["error"].as_str().unwrap_or_default().contains("__savePresetAs"), "{kind}: {r}");
|
||||
assert_eq!(saved(&app), presets, "{kind}: no preset saved");
|
||||
// Save Preset… adds the field; the name can then be typed and OK saves the preset.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("__savePresetAs".into(), json!(""));
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__savePresetAs", "value": "Proofs", "action": "confirm"}));
|
||||
assert_eq!(r["result"]["result"]["name"], "Proofs", "{kind}: {r}");
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
assert_eq!(saved(&app), (1, 1));
|
||||
}
|
||||
|
||||
/// The refusal of an OK on an install question automation asked.
|
||||
const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself.";
|
||||
|
||||
@@ -1532,7 +1758,8 @@ fn automation_cannot_approve_replacing_a_plugin() {
|
||||
assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND}));
|
||||
let evil = vectorcraft_format::base64_encode(&plain(r#"{"objects":[{"name":"x"}]}"#));
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": evil, "replace": true}}]}));
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
let protected = format!("the `{}` dialog cannot set protected field `__params`", crate::dialogs::plugin::INSTALL_KIND);
|
||||
assert_eq!(r["error"], protected, "{r}");
|
||||
super::plugins::show_approval(&mut app);
|
||||
let r = request(&mut app, &tx, "ui.dialog.confirm", json!({}));
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("Only the person at the keyboard")), "{r}");
|
||||
|
||||
@@ -759,6 +759,39 @@ mod gradient_chip_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient that repeats or reflects as many times as a chip has cells (8 on a 24-point chip)
|
||||
/// still shows its runs: the grid gets more cells than runs, so its points do not all sample
|
||||
/// the same place in a run.
|
||||
#[test]
|
||||
fn a_chip_repeated_once_a_cell_is_not_one_color() {
|
||||
for spread in [vectorcraft_color::GradientSpread::Repeat, vectorcraft_color::GradientSpread::Reflect] {
|
||||
let mut gp = paint(GradientKind::Linear, 0.0);
|
||||
gp.gradient.spread = spread;
|
||||
gp.gradient.repeats = 8;
|
||||
let (cols, rows) = chips::chip_grid(&gp.gradient, 24.0, 24.0);
|
||||
assert!(cols > 8 && rows > 8, "{spread:?}: {cols} x {rows}");
|
||||
let chip = gradient_chip_colors(&gp, 24.0, 24.0, cols, rows);
|
||||
let row: std::collections::HashSet<Color32> = chip.iter().take(cols + 1).copied().collect();
|
||||
assert!(row.len() > 2, "{spread:?}: {row:?}");
|
||||
}
|
||||
// A gradient that pads keeps a cell about every 3 points.
|
||||
assert_eq!(chips::chip_grid(&Gradient::default(), 24.0, 24.0), (8, 8));
|
||||
// Many runs give a small chip at most a cell a point.
|
||||
let mut many = paint(GradientKind::Linear, 0.0).gradient;
|
||||
many.spread = vectorcraft_color::GradientSpread::Repeat;
|
||||
many.repeats = 100;
|
||||
assert_eq!(chips::chip_grid(&many, 24.0, 12.0), (24, 12));
|
||||
assert_eq!(chips::chip_grid(&many, 400.0, 400.0), (128, 128));
|
||||
// A count that matches a chip's points still shows its runs.
|
||||
let mut gp = paint(GradientKind::Linear, 0.0);
|
||||
gp.gradient.spread = vectorcraft_color::GradientSpread::Repeat;
|
||||
gp.gradient.repeats = 23;
|
||||
let (cols, rows) = chips::chip_grid(&gp.gradient, 23.0, 23.0);
|
||||
let chip = gradient_chip_colors(&gp, 23.0, 23.0, cols, rows);
|
||||
let row: std::collections::HashSet<Color32> = chip.iter().take(cols + 1).copied().collect();
|
||||
assert!(row.len() > 2, "{cols}: {row:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_chip_runs_from_the_centre_out() {
|
||||
let colors = gradient_chip_colors(&paint(GradientKind::Radial, 0.0), 20.0, 20.0, 4, 4);
|
||||
|
||||
@@ -33,11 +33,7 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient
|
||||
if !(rect.width() > 0.0 && rect.height() > 0.0) {
|
||||
return;
|
||||
}
|
||||
// Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates).
|
||||
let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform;
|
||||
let max = if freeform { 16 } else { 64 };
|
||||
let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max);
|
||||
let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max);
|
||||
let (cols, rows) = chip_grid(&gradient.gradient, rect.width(), rect.height());
|
||||
let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows);
|
||||
// Stops below full opacity show a checkerboard through them.
|
||||
if colors.iter().any(|c| c.a() < u8::MAX) {
|
||||
@@ -59,6 +55,38 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient
|
||||
ui.painter().add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
/// Most grid cells across (and down) a chip of a gradient that repeats or reflects.
|
||||
const SPREAD_CHIP_CELLS: usize = 128;
|
||||
|
||||
/// The grid (columns, rows) a `width` x `height` chip of gradient `g` is sampled on: a cell about
|
||||
/// every 3 points (at most 64 a side, 16 for a freeform gradient, whose sampling is costly per
|
||||
/// spot; the mesh interpolates between them), and for a gradient that repeats or reflects at
|
||||
/// least 4 cells a run along each side (at most one a point, and [`SPREAD_CHIP_CELLS`]), so the
|
||||
/// grid's points do not all fall at the same place in a run and paint the chip one color. For
|
||||
/// such a gradient a side gets a cell or two more when its points would fall at fewer than three
|
||||
/// places in a run.
|
||||
pub(super) fn chip_grid(g: &vectorcraft_color::Gradient, width: f32, height: f32) -> (usize, usize) {
|
||||
let freeform = g.kind == vectorcraft_color::GradientKind::Freeform;
|
||||
let max = if freeform { 16 } else { 64 };
|
||||
let runs = if g.is_spread() { g.repeat_count() as usize } else { 1 };
|
||||
let least = if runs > 1 { (4 * runs).min(SPREAD_CHIP_CELLS) } else { 1 };
|
||||
let gcd = |mut a: usize, mut b: usize| {
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
a
|
||||
};
|
||||
let side = |len: f32| {
|
||||
let mut cells = ((len / 3.0).ceil() as usize).clamp(1, max).max(least.min(len.ceil() as usize));
|
||||
// Points `i / cells` of the way along fall at `cells / gcd(cells, runs)` places in a run.
|
||||
while runs > 1 && cells / gcd(cells, runs) < 3 {
|
||||
cells += 1;
|
||||
}
|
||||
cells
|
||||
};
|
||||
(side(width), side(height))
|
||||
}
|
||||
|
||||
/// A white and light gray checkerboard filling `rect`, with cells a quarter of its shorter side
|
||||
/// (3 to 8 points).
|
||||
fn checkerboard(ui: &Ui, rect: Rect) {
|
||||
|
||||
@@ -102,7 +102,7 @@ the two can still win that race.
|
||||
| `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above |
|
||||
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do |
|
||||
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) |
|
||||
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes |
|
||||
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences; Save PDF's and Print's `__savePresetAs`, the preset name, only while Save Preset… asks for it, since OK then saves a preset), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `<kind>` dialog cannot set protected field `<field>`", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes |
|
||||
| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
|
||||
| `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
|
||||
| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
|
||||
|
||||
@@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl
|
||||
|---|---|
|
||||
| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
|
||||
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing |
|
||||
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none |
|
||||
| Turntable (30.0 beta) | none |
|
||||
| Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) |
|
||||
|
||||
+75
-13
@@ -227,7 +227,8 @@ the suite) keep the agent to the folders you give it:
|
||||
leaves out the link's `relative`, `size` and `modified`. This covers the art of the brushes in `unknown.brushes`
|
||||
too. A link whose file is inside them keeps its `path` but leaves out a `relative` that leads outside them. A
|
||||
stored brush entry that does not read as a brush is left out of `unknown.brushes`. `document.json` also names a data set's image
|
||||
value outside them by its file name alone, and leaves out the `folder` of `export_settings` (the settings Export
|
||||
value and a linked JavaScript file in `svg_scripts` outside them by its file name alone (a JavaScript file given
|
||||
as a URL is kept), and leaves out the `folder` of `export_settings` (the settings Export
|
||||
for Screens last used) when that folder is outside them. The document itself is not changed.
|
||||
- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths
|
||||
outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in
|
||||
@@ -617,7 +618,7 @@ sampler.
|
||||
| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. |
|
||||
| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). |
|
||||
| `type_text` | `{text}` | Remote only. |
|
||||
| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. |
|
||||
| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `set` cannot change a `__` field other than the dialog's controls held under a `__` name (such as Save PDF's `__section` or the Preferences `__search`): the `__command` and `__params` of a `confirm` or `command` dialog, and every other `__` field that holds what OK acts on, are refused with "the `<kind>` dialog cannot set protected field `<field>`", so OK runs what the dialog was opened for. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. |
|
||||
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). |
|
||||
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
|
||||
| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. |
|
||||
@@ -867,7 +868,11 @@ a path whose extension names another format has
|
||||
it replaced by the template's extension, and any other path that does not end in that extension has it added, so
|
||||
`card.png` is saved as `card.vctemplate` and `card` as `card.vctemplate`; the suggested name and the Templates folder
|
||||
come back without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOptions {format?}` (a format's options
|
||||
with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF
|
||||
with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`.
|
||||
In the replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, `warnings`
|
||||
lists what the file leaves out of the document or writes differently first. The notes on the options follow: an
|
||||
option not applied or applied another way while the art reaches the file as it looks, or something an option turns
|
||||
off, such as `includeImages: false` in DXF. Each kind keeps the order the writers give it. A document saved as SVG or PDF
|
||||
remembers its options, so the next save reuses them; a document not yet saved as a native file is offered the
|
||||
VectorCraft Options the last native Save or Save As used (not its artboards, nor Use Compression). A PDF opened with `pages`, `cropTo` or `password` is only part
|
||||
of the file, so Save asks for a new name instead of writing it back.
|
||||
@@ -979,7 +984,7 @@ Long-tail commands:
|
||||
PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibility`, the General toggles and the
|
||||
`compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf`
|
||||
documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`:
|
||||
options accepted but not applied yet, and features approximated or left out. An image whose pixel data can't be
|
||||
features approximated or left out, then options accepted but not applied yet. An image whose pixel data can't be
|
||||
decoded (a PNG cut short) is left out of the pages, and `warnings` holds the note "an image couldn't be decoded and
|
||||
is missing from the picture" once, as raster exports give it; `.ai` saves and `file.print` do the same. A standard with a
|
||||
PDF version it doesn't allow is refused (PDF/A-2b at 1.3 or 2.0, PDF/X-4 above 1.6, PDF/X-1a and PDF/X-3 above 1.4);
|
||||
@@ -1270,11 +1275,19 @@ is written with its stops repeated as many times as the shape needs: at most 256
|
||||
legacy `.ai`), and for a radial or diamond gradient no farther from its center than 65,536 runs, or than a million
|
||||
times the smallest gap between its stops (measured in runs) when that is less. Past those it pads, with a warning.
|
||||
The `.ai` editing data has no spread either: it holds the stops repeated across the shape, at most 256 of them, with
|
||||
a warning. PDF import finds a sharp step between two colors in an axial or radial shading, in any PDF, by halving
|
||||
the spans between its samples, so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis, with at
|
||||
most 16,384 samples a shading) instead of blurred across the samples around it. Object › Expand expands the
|
||||
repeated stops, into the same whole number of `steps` objects for each run (at least one, so about `steps` in all),
|
||||
each run starting on an object's edge:
|
||||
a warning. When the runs need more stops than these limits allow, every run is still written, without the stops that
|
||||
change its colors least. Each run keeps its ends; sharp steps and swatch-linked stops are the last to be left out,
|
||||
but a run with more of them than its share of the limit loses some. The same applies to a gradient along or across a
|
||||
stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. For CSS the 16,384
|
||||
counts the gradient's stops before they are written: CSS then adds a stop for each midpoint, and for a Perceptual
|
||||
gradient stops along each span's curve, so the written gradient can hold more. PDF
|
||||
import finds a sharp step between two colors in an axial or radial shading by halving the spans between its samples,
|
||||
so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis) instead of blurred across the samples
|
||||
around it. A shading takes at most 16,384 samples, so the steps past the point where that budget runs out stay
|
||||
blurred, and a color band narrower than 1/64 of the axis can be missed when no sample lands in it. Object › Expand
|
||||
expands the repeated stops, into the same whole number of `steps` objects for each run (at least one, so about
|
||||
`steps` in all), each run starting on an object's edge; a gradient that needs more than 256 runs across the box stays
|
||||
a gradient:
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"spread":"reflect","repeats":3}}}
|
||||
@@ -3412,6 +3425,28 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"threeDType":"inflate","params":{"volume":70}}}}
|
||||
```
|
||||
|
||||
## Expand as Wireframes
|
||||
|
||||
`effect.expandAsWireframes {ids?}` (**Window › 3D and Materials** › Quick Actions › Expand as Wireframes) replaces
|
||||
each selected object, or each object in `ids`, that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate,
|
||||
Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of
|
||||
the faces the effect draws for the object as it is turned and projected now
|
||||
([three-d.md](three-d.md#3d-and-materials-panel)). Each edge appears once; edges that meet end to end form one
|
||||
path, closed where it comes back to its start, and curved edges stay curves. Geometry effects after a 3D effect,
|
||||
on the object or on a fill or stroke, reshape its edges as they reshape the drawn surface. The paths have a 0.5 pt
|
||||
black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name,
|
||||
visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups
|
||||
are selected.
|
||||
A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides and paths made into guides stay. A path inside a compound path in `ids` stands for the compound path. Objects without a visible 3D effect do not change. It is one undo step and
|
||||
returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object
|
||||
has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than
|
||||
1,000,000 edges.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAsWireframes","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAsWireframes","params":{"ids":[12]}}}
|
||||
```
|
||||
|
||||
## Extrude & Bevel, Inflate and Rotate
|
||||
|
||||
`effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies
|
||||
@@ -6468,12 +6503,16 @@ result carries the same files as a zip (`{name: "<name>.zip", dataBase64}`, entr
|
||||
|
||||
`document.info {selectionOnly?, category?, format?: "text"}` adds `sections` (`[{id, title, rows: [[label, value]]}]`:
|
||||
document, objects, graphicStyles, brushes, spotColors, patterns, gradients, symbols, fonts, fontDetails, linkedImages,
|
||||
embeddedImages, repeats), `brushNames` (the Brushes panel's brushes; with `selectionOnly`, the brushes the selected
|
||||
objects' strokes use), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in
|
||||
embeddedImages, repeats), `brushNames` (the brushes the art's strokes use, each once, hidden strokes, strokes at an opacity of 0, strokes with no
|
||||
paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a
|
||||
shape left out; with `selectionOnly`, the brushes the selected
|
||||
objects' strokes use), `brushes` (`[{name, type}]`: those brushes with their types, `calligraphic`, `scatter`, `art`,
|
||||
`pattern` or `bristle`, and `null` for a brush the Brushes panel does not hold; the brushes category's rows give each
|
||||
name with its type, such as `Art brush`, or `missing from the Brushes panel`), `panelBrushNames` (the Brushes panel's brushes, whether art uses them or not), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in
|
||||
groups and in other repeats included, with its type `radial`, `grid` or `mirror` and the number of instances it makes,
|
||||
and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection), `patternObjects`
|
||||
(`[{id, name, patterns}]`: every object filled or stroked with a pattern, once each, those in groups and type objects
|
||||
whose drawn characters use a pattern included, hidden fills and strokes, strokes with no width, guides, a compound path
|
||||
whose drawn characters use a pattern included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, a compound path
|
||||
member's own fills and strokes, an image's fills and strokes that no Convert to Shape or Outline Object effect gives a
|
||||
shape, and characters that draw nothing (spaces and line breaks without an underline or strikethrough) left out, with
|
||||
the patterns it uses, which the patterns category lists as Pattern Objects and `objects.patterns` counts; with `selectionOnly`, those in the selection),
|
||||
@@ -6562,7 +6601,8 @@ the later file is written as `-2`, `-3`… (`Artboard-1-2.png`) and listed under
|
||||
rows write one file, and row folders that differ only in case (`Print`, `print`) are one folder, spelled as the first
|
||||
row spells it. The document remembers the last params: `document.exportSettings`. As in `document.export`,
|
||||
the reply's `warnings` lists the notes of every file exported (an image that couldn't be decoded, type written in the
|
||||
fallback font…), each note once; the export still writes or returns every file.
|
||||
fallback font…), each note once, what any of the files loses before the notes on the options; the export still
|
||||
writes or returns every file.
|
||||
|
||||
Batch exports (#1935):
|
||||
- **What:** `layers: [id…]` (each layer's art alone) or `selection: "one" | "each"` instead of artboards
|
||||
@@ -8402,6 +8442,28 @@ stops, the command changes nothing and records no undo step. `text.tabs.set` rej
|
||||
holds at least that many, so stops can be removed from a paragraph that holds more. It sorts the stops by position and
|
||||
keeps the first of stops at one position.
|
||||
|
||||
`text.setFormat {paragraphIndents: [{id, start, leftIndent?, firstLineIndent?}…]}` gives each paragraph indents of its
|
||||
own in one call: each entry names one paragraph of a type object by the byte `start` where the paragraph begins, and
|
||||
sets its `leftIndent`, its `firstLineIndent` or both, in points from -1296 to 1296. The call takes no other key, and
|
||||
at most 100000 entries. It checks every entry before it changes anything, and fails on an empty list, an unknown key, an
|
||||
entry with neither indent, a value that is not a number in range, an `id` that is not a type object, a `start` where no
|
||||
paragraph of that object begins, and two entries for one paragraph with other values (the same entry twice counts once).
|
||||
The whole change is one edit: each type object's paragraph styles are rebuilt once, and a threaded story flows through
|
||||
its frames again once, however many of its paragraphs change, where one `text.setFormat {ids, start, end}` for each
|
||||
paragraph flows it again after every paragraph. When no paragraph's indents change, the command records no edit and no
|
||||
undo step. It returns `{ids}`, the type objects the entries name. The Tabs panel's indent markers use it when the
|
||||
paragraphs they move have indents of their own, inside a `command.batch` that names the undo step after the marker.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"paragraphIndents":[{"id":12,"start":0,"firstLineIndent":36},{"id":12,"start":40,"leftIndent":24,"firstLineIndent":12}]}}}
|
||||
```
|
||||
|
||||
The single-value form of `text.setFormat` fails, naming the key, when a number such as `leftIndent` or `baselineShift` is
|
||||
given as a string, a switch or a list, or a switch such as `underline` as anything but true or false; it changes nothing
|
||||
then. A null value of any attribute leaves that attribute as it is, as an omitted one does, so a call whose attributes
|
||||
are all null fails with "nothing to change" and records no edit and no undo step. A call whose values are the ones the
|
||||
text already has succeeds and returns its `ids`, but records no edit and no undo step.
|
||||
|
||||
`text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the
|
||||
paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in
|
||||
the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and
|
||||
|
||||
@@ -234,7 +234,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested |
|
||||
| Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) |
|
||||
| Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review |
|
||||
| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces; materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step` |
|
||||
| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged) |
|
||||
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged |
|
||||
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged |
|
||||
| Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated |
|
||||
@@ -568,6 +568,7 @@ since 2026-10-01.
|
||||
- 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo.
|
||||
- 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged.
|
||||
- Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab.
|
||||
- 3D and Materials › Quick Actions › Expand as Wireframes (#6354): `effect.expandAsWireframes` replaces each selected 3D object with a group of 0.5 pt black stroked paths, the edges of the faces its effect draws, each edge once and joined end to end, curves kept, in one undo step ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs`, `crates/engine/src/cmd/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged.
|
||||
- 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged.
|
||||
|
||||
- **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged.
|
||||
@@ -586,6 +587,7 @@ since 2026-10-01.
|
||||
- **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged.
|
||||
- **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged.
|
||||
- **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged.
|
||||
- **What a save or an export loses, named first:** Each writer marks its notes as a loss (something of the document the file leaves out or writes differently) or a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The replies of the save and export commands list the losses first, each kind in the order given, across every file an export writes. The status message after a save or an export then names a loss when there is one: an export to lossy WebP of type in a font that isn't installed names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in. Scores and estimates are unchanged.
|
||||
|
||||
- **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged.
|
||||
- **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged.
|
||||
@@ -661,7 +663,7 @@ since 2026-10-01.
|
||||
- **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
|
||||
- **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions.
|
||||
- **Moving all tab stops:** in the Tabs panel, a stop dragged on the ruler with Ctrl held (Command on macOS) moves every stop by the same distance, in one `text.tabs.set` and one undo step; the stops keep their order, alignment and leaders, the ruler shows them moving during the drag, and a drag that would take the leftmost stop below 0 leaves it at 0, or the rightmost past 100,000 pt leaves it at 100,000 pt, with the other stops at their spacing from it. A drag that leaves every stop where it was records no undo step. Ctrl must be held when the drag starts. Tests `panels::tabs::tests::command_drag_moves_all_the_stops_together`, `command_drag_stops_at_zero_and_keeps_the_spacing`, `command_drag_stops_at_the_largest_position_and_keeps_the_spacing` and `a_drag_that_moves_no_stop_records_no_undo_step`. Public reference: [Manage tab stops](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/manage-tab-stops.html), which says that a Ctrl-drag (Windows) or Command-drag (macOS) of a tab moves all the tab stops at the same time.
|
||||
- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat` for each paragraph of the story, from its last paragraph to its first, whatever order the frames were selected in with the Selection tool; a paragraph that runs on from one frame into the next gets one step, after Type tool Select All too. With the Selection tool only the paragraphs with text in a selected frame change, so a frame selected alone that starts or ends inside a paragraph sets that paragraph. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_after_select_all_list_a_split_paragraph_once` and `indent_markers_on_some_threaded_frames_list_their_paragraphs_once`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html).
|
||||
- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat {paragraphIndents}` with an entry for each paragraph of the story, whatever order the frames were selected in with the Selection tool, so the story flows through its frames again once rather than once for each paragraph, and a drag that changes no paragraph runs nothing; a paragraph that runs on from one frame into the next gets one entry, after Type tool Select All too. A selected story is one text: with the Selection tool, a marker drag on any of its frames sets every paragraph of the story, in its locked and hidden frames too. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_over_many_threaded_paragraphs_send_one_format_command`, `indent_markers_after_select_all_list_a_split_paragraph_once`, `indent_markers_on_some_threaded_frames_list_the_whole_story_once`, `indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames`, `cmd::threads::tests::paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would`, `paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once` and `paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing`, and `cmd::typecmd::area_tests::set_format_refuses_a_param_of_the_wrong_type`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html).
|
||||
- **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html).
|
||||
Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands.
|
||||
|
||||
@@ -1161,6 +1163,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy).
|
||||
- A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html).
|
||||
- A butt-capped curve whose first or last handle sits on its end point also stops at that end: a straight 50 pt curve with a 2 pt stroke and its first handle on its start point measures 50 pt tall, not 51 pt (doc `reach::tests::a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end`).
|
||||
- A bevel join, and a miter join whose miter limit bevels the corner, end the stroke at the corners of the two segments they join. VectorCraft's visual box of such a stroke, on closed and open paths, reaches a beveled corner only as far as those corners: a 40 × 100 pt triangle with a 20 pt stroke and a miter limit of 2 reaches about 1.96 pt above its tip instead of 10 pt. Round joins and the round caps of dashes, which can end at a corner, still reach half the weight round it, and the projecting caps of dashes reach the diagonal of their square. With Use Preview Bounds on, `object.setBounds` measures an object as drawn when a beveled or mitered corner, or a butt cap, is on a segment at a slant, so a width and height out of proportion give the size typed. A click past a bevel or a butt cap, by more than the click tolerance, does not select the stroke, except on a stroke with a width profile or with dashes that have round or projecting caps, and at the end of a curve that bends more tightly than half the weight. Public references: [Apply stroke color, width, and alignment](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/apply-stroke-color-width-and-alignment.html), [PDF 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.4.3.4 "Line Join Style" and 8.4.3.5 "Miter Limit" (doc `reach::tests::a_bevel_stops_at_the_corners_of_the_segments_it_joins` and `a_click_past_a_bevel_or_a_butt_end_misses`, effects `stroke::tests_reach::beveled_corners_are_measured_where_they_paint`, engine `tests::previewbounds::set_bounds_gives_a_closed_path_with_beveled_or_mitered_corners_the_size_out_of_proportion`).
|
||||
- With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy).
|
||||
- VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size; the font size follows the vertical scale, so a W typed alone, out of proportion, changes the horizontal scale instead. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`).
|
||||
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)).
|
||||
@@ -1390,9 +1393,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Bristle Brush Options and `brush.new` take a bristle brush name of at most 31 characters, as the Brushes page states for the Bristle Brush Options. The Name field stops taking characters at 31, and `brush.new` and `brush.options` refuse a longer new name with an error that names the limit. The page does not say how the limit counts characters or what happens to longer names already saved; counting characters and the number added to a taken name, keeping longer saved names, and leaving `brush.duplicate` unlimited are VectorCraft's own rules. A bristle brush that a document or a saved file already holds under a longer name keeps it, and the other brush types take a name of any length. Test: `bristle_brush_names_have_at_most_31_characters` in `tests/brushsym.rs`. Public reference: [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) (archived copy).
|
||||
In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`).
|
||||
The Brushes panel's Delete Brush button and panel menu item ask "Delete the selected brushes?" with Yes and No when none of the brushes is in use. Alt-click (Option-click on macOS) on the button deletes them without asking, as the [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for panel deletion outside the Layers panel (archived copy). When a stroke uses one of the brushes, the button, Alt-click and the menu item all ask with Remove Strokes and Cancel; Remove Strokes deletes the brushes and the strokes that used them become plain strokes. Tests: `panels::brushes::tests`, `dialogs::brush_delete::tests`.
|
||||
Document Info has a Brushes category after Graphic Styles. It lists the Brushes panel's brushes, or with Selection Only it lists the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`).
|
||||
Document Info has a Brushes category after Graphic Styles. It lists the names and types of the brushes applied to the artwork, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, or with Selection Only the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`, `brushes`). Listing each brush once, leaving out the Brushes panel's brushes no art uses (`panelBrushNames` lists the panel's brushes), hidden strokes, strokes at an opacity of 0, strokes with no paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a shape, and listing a brush the panel does not hold as missing, are VectorCraft's own rules. Tests: `cmd::docinfo::tests::info_lists_the_brushes` and `info_brushes_skip_undrawn_strokes_and_name_missing_brushes` (vectorcraft-engine).
|
||||
Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`).
|
||||
Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine).
|
||||
Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine).
|
||||
Document Info's Gradient Objects category lists the objects whose fill or stroke is a gradient, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as the count of objects with gradient fills or strokes. Each object is listed by name with the gradients it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "gradients"}`, `gradientObjects`). Naming a linked gradient by its swatch and any other gradient by its kind, and leaving out paint that draws nothing by the rules Pattern Objects follows, gradient swatches no art uses, gradient meshes and gradients drawn by effects, are VectorCraft's own rules. Objects counts the listed objects as Gradient Objects. Test: `cmd::docinfo::tests::info_lists_the_gradient_objects` (vectorcraft-engine).
|
||||
Document Info's Spot Color Objects category lists the objects that use spot colors, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as objects using spot color separations. Each object is listed by name with the spot colors it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "spotColors"}`, `spotObjects`). Taking a solid color or a gradient stop linked to a spot swatch at a tint above 0 and a grayscale image colorized with one, leaving out process colors that look the same, Registration, gradients whose stops are all at an opacity of 0, fills and strokes at an opacity of 0, paint that draws nothing by the rules Pattern Objects follows, spot swatches no art uses and spot colors inside patterns, symbols, opacity masks, effects and placed files, are VectorCraft's own rules. Objects counts the listed objects as Spot Color Objects. Tests: `cmd::docinfo::tests::info_lists_the_spot_color_objects`, `spot_color_objects_take_colorized_images_and_skip_clear_gradients` and `spot_color_objects_skip_clear_fills_and_strokes` (vectorcraft-engine).
|
||||
Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`.
|
||||
@@ -1757,6 +1760,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
|
||||
- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
- **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged.
|
||||
- **A dialog's OK runs what it was opened for:** `ui.dialog.set`, and MCP's `answer_dialog` through it, can set a `__` field only when it is a control of that kind of dialog (a section or tab shown, a link between two fields, a name or search being typed). Every other `__` field, such as the `__command` and `__params` of a confirmation or the generic parameter dialog, a plug-in command dialog's `__command` and `__plugin`, or the paste a swatch conflict waits to finish, is refused with "the `<kind>` dialog cannot set protected field `<field>`"; the request sets no field, and with `action: "confirm"` OK does not run. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/control/tests.rs`. Scores and hours unchanged.
|
||||
- **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
- **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
- **An install automation asked for needs the person to start it (G17):** an approval question that a control request, MCP, injected input or a file dialog one of them opened leads to keeps OK disabled and refuses every confirm, a person's click included; it says to choose Object › Plug-ins › Install Plug-in…, and `ui.installPlugin` answers `approval: "personMustStartInstall"`. The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question. Automation's `replace: true` no longer skips the question about taking another module's place, and that question now opens in the approval dialog for everyone. VectorCraft's own rule. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user