mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 10:01:58 +00:00
Merge remote-tracking branch 'origin/main' into fix/6969-rasterize-gray
# Conflicts: # docs/target-app-parity.md
This commit is contained in:
Generated
+11
-11
@@ -217,7 +217,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -986,12 +986,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1060,7 +1060,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1620,7 +1620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3869,7 +3869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4391,7 +4391,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4769,7 +4769,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4828,7 +4828,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5687,7 +5687,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.6",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7517,7 +7517,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ repository = "https://github.com/storytold/vectorcraft"
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "bb8320f5fa5d20fa78a026d0de06fe4e2f08a099", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6", features = ["serde"] }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
|
||||
|
||||
@@ -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, 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,7 +348,8 @@ 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.
|
||||
@@ -364,7 +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 {
|
||||
if !item.visible() || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) {
|
||||
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()) {
|
||||
@@ -381,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");
|
||||
@@ -425,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![];
|
||||
@@ -569,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 {
|
||||
@@ -587,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,
|
||||
@@ -601,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(),
|
||||
@@ -612,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) }));
|
||||
}
|
||||
@@ -725,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
|
||||
@@ -867,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();
|
||||
@@ -927,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);
|
||||
}
|
||||
|
||||
@@ -969,6 +969,7 @@ impl Transaction {
|
||||
if let Some(saved) = &location.group {
|
||||
apply(&mut candidate, Action::ResizeGroup { panel: panel.clone(), height: saved.height })?;
|
||||
apply(&mut candidate, Action::ResizeFlyoutWidth { panel: panel.clone(), width: saved.flyout_width })?;
|
||||
apply(&mut candidate, Action::MoveFlyoutTop { panel: panel.clone(), offset: saved.flyout_offset })?;
|
||||
apply(&mut candidate, Action::SetMinimized { panel: panel.clone(), minimized: saved.minimized })?;
|
||||
}
|
||||
self.workspace.layout = candidate;
|
||||
@@ -1108,6 +1109,7 @@ fn known(action: &Action<String>) -> bool {
|
||||
| Action::ResizeColumn { panel, .. }
|
||||
| Action::ResizeGroup { panel, .. }
|
||||
| Action::ResizeFlyoutWidth { panel, .. }
|
||||
| Action::MoveFlyoutTop { panel, .. }
|
||||
| Action::ResizeIconic { panel, .. }
|
||||
| Action::SetNative { panel, .. }
|
||||
| Action::SetMonitor { panel, .. } => registered(panel),
|
||||
|
||||
@@ -204,6 +204,76 @@ fn glyphs_and_artboards_flyouts_keep_user_sizes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An icon flyout's top edge and its free top corner (the canvas-side one) resize it upward:
|
||||
/// the bottom and the icon-column edge stay, and the new top is kept when the flyout closes
|
||||
/// and reopens and in a saved layout. The docking action that saves it is one VectorCraft
|
||||
/// accepts (#1094).
|
||||
#[test]
|
||||
fn an_icon_flyout_grows_upward_from_its_top_edge_and_free_corner() {
|
||||
assert!(known(&Action::MoveFlyoutTop { panel: "glyphs".into(), offset: Some(-40.0) }));
|
||||
let mut h = Harness::new();
|
||||
let icon = h.hit(&Hit::Icon("glyphs".into())).unwrap().center();
|
||||
h.click(icon);
|
||||
for _ in 0..30 {
|
||||
h.frame(vec![]);
|
||||
}
|
||||
let rect = |h: &Harness| {
|
||||
let id = area_id().with("flyout");
|
||||
let left = h.ctx.read_response(id.with("resize-left")).unwrap().rect;
|
||||
let bottom = h.ctx.read_response(id.with("resize-bottom")).unwrap().rect;
|
||||
let top = h.ctx.read_response(id.with("resize-top")).unwrap().rect;
|
||||
Rect::from_min_max(pos2(left.left(), top.top()), pos2(bottom.right() + 12.0, bottom.bottom()))
|
||||
};
|
||||
let before = rect(&h);
|
||||
assert!(before.top() > 200.0, "room above the Glyphs flyout: {before:?}");
|
||||
for (at, cursor) in [
|
||||
(pos2(before.center().x, before.top() + 2.0), egui::CursorIcon::ResizeVertical),
|
||||
(before.left_top() + vec2(2.0, 2.0), egui::CursorIcon::ResizeNwSe),
|
||||
] {
|
||||
h.frame(vec![Event::PointerMoved(at)]);
|
||||
h.frame(vec![]);
|
||||
assert_eq!(h.cursor, cursor, "at {at:?}, frame {before:?}");
|
||||
}
|
||||
let from = pos2(before.center().x, before.top() + 2.0);
|
||||
h.drag(from, from - vec2(0.0, 80.0));
|
||||
for _ in 0..3 {
|
||||
h.frame(vec![]);
|
||||
}
|
||||
let raised = rect(&h);
|
||||
assert!((raised.top() - (before.top() - 80.0)).abs() < 1.0, "{before:?} -> {raised:?}");
|
||||
assert_eq!(raised.bottom(), before.bottom());
|
||||
assert_eq!(raised.right(), before.right(), "the icon-column edge stays anchored");
|
||||
let from = raised.left_top() + vec2(2.0, 2.0);
|
||||
h.drag(from, from + vec2(-60.0, -20.0));
|
||||
for _ in 0..3 {
|
||||
h.frame(vec![]);
|
||||
}
|
||||
let grown = rect(&h);
|
||||
assert!((grown.width() - raised.width() - 60.0).abs() < 1.0, "{raised:?} -> {grown:?}");
|
||||
assert!((grown.top() - (raised.top() - 20.0)).abs() < 1.0, "{raised:?} -> {grown:?}");
|
||||
assert_eq!(grown.bottom(), before.bottom());
|
||||
assert_eq!(grown.right(), before.right());
|
||||
h.click(icon);
|
||||
assert_eq!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()), None);
|
||||
h.click(icon);
|
||||
for _ in 0..3 {
|
||||
h.frame(vec![]);
|
||||
}
|
||||
assert_eq!(rect(&h), grown, "reopened where it was");
|
||||
let saved = serde_json::to_string(&h.layout()).unwrap();
|
||||
assert!(saved.contains("flyout_offset"));
|
||||
let mut restored = Harness::new();
|
||||
restored.app.ui.docking = Some(serde_json::from_str(&saved).unwrap());
|
||||
for _ in 0..3 {
|
||||
restored.frame(vec![]);
|
||||
}
|
||||
restored.click(restored.hit(&Hit::Icon("glyphs".into())).unwrap().center());
|
||||
for _ in 0..3 {
|
||||
restored.frame(vec![]);
|
||||
}
|
||||
assert_eq!(rect(&restored), grown, "saved layout");
|
||||
}
|
||||
|
||||
/// Footer controls stay at the resized panel's bottom, including empty or short lists.
|
||||
/// The same shared footer is used by the other icon panels and library windows.
|
||||
#[test]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user