diff --git a/ASSETS.md b/ASSETS.md index 5658882d48..dbdd8b84d8 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -20,7 +20,7 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo **Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed. The one exception is the ArtCraft name, wordmark and logos in `docs/brand/`: ArtCraft trademarks, not open source, usable only under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). -Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`). +Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets, the vector tool cursors (`crates/ui-egui/src/cursors.rs`) and the Learn panel's practice samples (`crates/ui-egui/src/panels/learn/samples.rs`). **Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). The small Latin UI/document fonts below predate that rule and stay. diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index 93c0ad0f37..05c386e405 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -567,6 +567,112 @@ pub fn theme_palette(samples: &[(Color, f32)], n: usize) -> Vec { groups.into_iter().map(|g| g.2).collect() } +/// Colors that [`weigh`] moves from one row to another as one: a color they are shown by, how +/// often the art uses them and the row they are in. A fixed unit (such as the color a row is +/// shown by) stays in its row. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WeighUnit { + pub color: Color, + pub uses: f64, + pub row: usize, + pub fixed: bool, +} + +/// The unit-by-row comparisons one [`weigh`] makes, at most. +const WEIGH_WORK: usize = 50_000_000; + +/// The uses of `u` that count: a positive finite number, else 0. +fn unit_uses(u: &WeighUnit) -> f64 { + if u.uses.is_finite() && u.uses > 0.0 { u.uses } else { 0.0 } +} + +/// Each row's share of the uses of `units`, in percent (0 for every row when nothing is used). +/// Units whose row is past `rows`, or whose uses are not a positive finite number, count nothing. +pub fn row_shares(units: &[WeighUnit], rows: usize) -> Vec { + let mut sums = vec![0.0; rows]; + for u in units { + if let Some(s) = sums.get_mut(u.row) { + *s += unit_uses(u); + } + } + let total: f64 = sums.iter().sum(); + if total <= 0.0 { + return vec![0.0; rows]; + } + sums.iter().map(|s| s / total * 100.0).collect() +} + +/// For Recolor Artwork's Prominent Colors (our own rule): the row each of `units` goes to so that +/// each row's share of the uses comes as near its share of `weights` as whole units allow. One +/// unit moves at a time, while a move brings the rows' shares nearer their weights (the sum of +/// every row's distance from its weight falls); of those moves, the one taken is the unit nearest +/// in Lab (CIEDE2000) to the color of the row it goes to (`anchors`), compared with the row it +/// leaves; on a tie, the move that brings the shares nearest. Fixed units, units in a row past +/// `anchors` and units used no times stay where they are. Weights that are negative or not finite +/// count as 0; when no weight is positive, every unit stays. The moves stop after +/// [`WEIGH_WORK`] comparisons of a unit with a row, and when the movable units times the rows pass +/// it, every unit stays. +pub fn weigh(units: &[WeighUnit], anchors: &[Color], weights: &[f64]) -> Vec { + let mut row: Vec = units.iter().map(|u| u.row).collect(); + let n = anchors.len(); + let given = |r: usize| weights.get(r).copied().filter(|x| x.is_finite() && *x > 0.0).unwrap_or(0.0); + // Scaled by the largest weight first, so the sum stays finite. + let top = (0..n).map(given).fold(0.0, f64::max); + let w = |r: usize| if top > 0.0 { given(r) / top } else { 0.0 }; + let sum_w: f64 = (0..n).map(w).sum(); + let movable: Vec = (0..units.len()).filter(|&i| !units[i].fixed && units[i].row < n && unit_uses(&units[i]) > 0.0).collect(); + let per_pass = movable.len().saturating_mul(n); + if sum_w <= 0.0 || n < 2 || movable.is_empty() || per_pass > WEIGH_WORK { + return row; + } + let mut sums = vec![0.0; n]; + for u in units.iter().filter(|u| u.row < n) { + sums[u.row] += unit_uses(u); + } + let total: f64 = sums.iter().sum(); + let target: Vec = (0..n).map(|r| w(r) / sum_w * total).collect(); + let anchor_labs: Vec = anchors.iter().map(|c| c.to_lab()).collect(); + // Each movable unit's distance to each row's color. + let dist: Vec> = movable + .iter() + .map(|&i| { + let l = units[i].color.to_lab(); + anchor_labs.iter().map(|a| delta_e2000(*a, l)).collect() + }) + .collect(); + // A move must bring the shares nearer by more than rounding. + let eps = total * 1e-9; + for _ in 0..WEIGH_WORK / per_pass { + let mut best: Option<(f32, f64, usize, usize)> = None; + for (m, &i) in movable.iter().enumerate() { + let (a, x) = (row[i], unit_uses(&units[i])); + let before_a = (sums[a] - target[a]).abs(); + let after_a = (sums[a] - x - target[a]).abs(); + for b in (0..n).filter(|&b| b != a) { + let gain = before_a + (sums[b] - target[b]).abs() - after_a - (sums[b] + x - target[b]).abs(); + if gain <= eps { + continue; + } + let cost = dist[m][b] - dist[m][a]; + let better = match best { + None => true, + Some((c, g, ..)) => cost < c || (cost == c && gain > g), + }; + if better { + best = Some((cost, gain, m, b)); + } + } + } + let Some((_, _, m, b)) = best else { break }; + let i = movable[m]; + let x = unit_uses(&units[i]); + sums[row[i]] -= x; + sums[b] += x; + row[i] = b; + } + row +} + /// A set of colours to snap to (Limit to Library), with their Lab values cached. #[derive(Clone, Debug, Default)] pub struct Palette(Vec<(Color, Lab)>); @@ -911,4 +1017,55 @@ mod tests { assert!(matches!(Rng::new(3).saturation_brightness(Color::lab(50.0, 40.0, 20.0)), Color::Lab { .. })); assert_eq!(Rng::new(3).saturation_brightness(Color::gray(0.4)).to_hsb()[1], 0.0, "greys stay grey"); } + + fn unit(color: Color, uses: f64, row: usize, fixed: bool) -> WeighUnit { + WeighUnit { color, uses, row, fixed } + } + + #[test] + fn row_shares_are_percent_of_the_uses() { + let units = [unit(Color::rgb(1.0, 0.0, 0.0), 3.0, 0, true), unit(Color::rgb(0.0, 0.0, 1.0), 1.0, 1, true), unit(Color::WHITE, 5.0, 4, false)]; + assert_eq!(row_shares(&units, 2), vec![75.0, 25.0]); + assert_eq!(row_shares(&[], 2), vec![0.0, 0.0]); + assert_eq!(row_shares(&[unit(Color::WHITE, f64::NAN, 0, false)], 1), vec![0.0]); + } + + #[test] + fn weigh_moves_the_nearest_colors_toward_the_weights() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let units = [ + unit(red, 1.0, 0, true), + unit(Color::rgb(0.9, 0.1, 0.3), 1.0, 0, false), + unit(Color::rgb(0.5, 0.0, 0.8), 1.0, 0, false), + unit(blue, 1.0, 1, true), + ]; + // Equal weights: the reddish purple, nearer blue than the red-pink is, moves. + assert_eq!(weigh(&units, &[red, blue], &[1.0, 1.0]), vec![0, 0, 1, 1]); + // Most of the weight on blue: both movable colors go, the row's own color stays. + assert_eq!(weigh(&units, &[red, blue], &[0.0, 1.0]), vec![0, 1, 1, 1]); + // Weights already met, or none positive: nothing moves. + assert_eq!(weigh(&units, &[red, blue], &[3.0, 1.0]), vec![0, 0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[0.0, f64::NAN]), vec![0, 0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[1e308, 1e308]), vec![0, 0, 1, 1], "huge weights act as equal ones"); + assert_eq!(weigh(&units, &[red], &[1.0]), vec![0, 0, 0, 1], "a row past the anchors keeps its units"); + } + + #[test] + fn weigh_takes_whole_units_and_stops_when_no_move_helps() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + // A heavy unit: moving it would overshoot by more than staying misses. + let units = [unit(red, 1.0, 0, true), unit(Color::rgb(0.8, 0.0, 0.2), 10.0, 0, false), unit(blue, 10.0, 1, true)]; + assert_eq!(weigh(&units, &[red, blue], &[1.0, 1.4]), vec![0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[1.0, 20.0]), vec![0, 1, 1]); + // Many units: the result still follows the weights. + let many: Vec = (0..400).map(|i| unit(Color::rgb(i as f32 / 400.0, 0.2, 1.0 - i as f32 / 400.0), 1.0, 0, i == 0)).collect(); + let rows = weigh(&many, &[Color::rgb(0.0, 0.2, 1.0), Color::rgb(1.0, 0.2, 0.0)], &[1.0, 3.0]); + assert_eq!(rows.iter().filter(|r| **r == 1).count(), 300); + assert!(rows[399] == 1 && rows[1] == 0, "the reddest colors go to the red row"); + // Past the work limit (8,000 units by 7,000 rows) nothing moves. + let units: Vec = (0..8_000).map(|i| unit(Color::WHITE, 1.0, 0, i == 0)).collect(); + let anchors = vec![Color::BLACK; 7_000]; + let weights = vec![1.0; 7_000]; + assert!(weigh(&units, &anchors, &weights).iter().all(|r| *r == 0)); + } } diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d9e179286f..f4877b0a49 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1326,27 +1326,39 @@ impl Spine { let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 }; let sp = &self.subpath; let n = sp.segment_count(); - let mut start = 0.0; - for i in 0..n { - let c = sp.segment(i); - let end = self.anchor_length(i + 1).unwrap_or(self.length()); - if s <= end || i + 1 == n { - let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); - let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); - let e = c.eval(t); - let tv = c.deriv().eval(t).to_vec2(); - // Where a handle sits on its anchor the derivative vanishes there: take the - // direction between points a little before and after instead. - let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; - return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); - } - start = end; + if n == 0 { + return (self.pts.first().copied().unwrap_or_default(), 0.0); } - // Only a spine without segments gets here: the loop returns at the last one. - (self.pts.first().copied().unwrap_or_default(), 0.0) + let i = segment_at(&self.anchors, n, s, || {}); + let c = sp.segment(i); + let start = if i == 0 { 0.0 } else { self.anchor_length(i).unwrap_or(0.0) }; + let end = self.anchor_length(i + 1).unwrap_or(self.length()); + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); + let e = c.eval(t); + let tv = c.deriv().eval(t).to_vec2(); + // Where a handle sits on its anchor the derivative vanishes there: take the + // direction between points a little before and after instead. + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; + (Point::new(e.x, e.y), tv.y.atan2(tv.x)) } } +/// The segment of a spine with `n` segments (`n > 0`) that holds arc length `s`: the first one whose +/// end is at or past `s`, or the last one when none is. `anchors` holds the arc length at each +/// anchor, so segment `i` ends at `anchors[i + 1]`, and the last segment of a closed spine, which has +/// no entry for its end, ends at the spine's length. The lengths never decrease (each is a sum of +/// distances that are not negative, at most `+inf` and never NaN), so a binary search finds the +/// segment in about `log2(n)` comparisons. `probe` is called once for each comparison. +fn segment_at(anchors: &[f64], n: usize, s: f64, mut probe: impl FnMut()) -> usize { + let ends = anchors.get(1..n.min(anchors.len())).unwrap_or_default(); + ends.partition_point(|&end| { + probe(); + end < s + }) + .min(n.saturating_sub(1)) +} + /// The anchor of the first subpath each key sits on, when they are all valid for `n` anchors. fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { (spec.key_anchors.len() == keys && keys >= 2 && spec.key_anchors.iter().all(|a| (*a as usize) < n)) @@ -1891,6 +1903,76 @@ mod tests { assert!(end.is_finite(), "{end}"); } + /// [`Spine::at_length`] by walking the segments from the first, as a reference for the search. + fn walked_at_length(sp: &Spine, s: f64) -> (Point, f64) { + let s = if s.is_finite() { s.clamp(0.0, sp.length()) } else { 0.0 }; + let n = sp.subpath.segment_count(); + let mut start = 0.0; + for i in 0..n { + let c = sp.subpath.segment(i); + let end = sp.anchor_length(i + 1).unwrap_or(sp.length()); + if s <= end || i + 1 == n { + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); + let e = c.eval(t); + let tv = c.deriv().eval(t).to_vec2(); + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; + return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); + } + start = end; + } + panic!("a spine without segments") + } + + /// The search picks the same segment as walking them in order, so points and tangents stay the + /// same: on open and closed spines, one segment, curves, and zero-length segments whose anchors + /// share an arc length; at every anchor, between anchors, at both ends, past them, and for + /// values that are not finite. + #[test] + fn spine_lookup_matches_walking_the_segments() { + let p = Point::new; + let bow = SubPath::new( + vec![Anchor::smooth(p(0.0, 0.0), p(50.0, 50.0)), Anchor::smooth(p(100.0, 0.0), p(150.0, -50.0)), Anchor::corner(p(200.0, 0.0))], + false, + ); + let spines = [ + SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(30.0, 5.0), p(30.0, 5.0)], false), + SubPath::polyline(&[p(0.0, 0.0), p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0)], true), + SubPath::polyline(&[p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0), p(0.0, 0.0)], true), + SubPath::polyline(&[p(0.0, 0.0), p(25.0, 25.0)], false), + SubPath::polyline(&[p(0.0, 0.0), p(25.0, 25.0)], true), + bow.clone(), + SubPath { closed: true, ..bow }, + ]; + for sub in spines { + let sp = Spine::new(&PathData::single(sub)).unwrap(); + let len = sp.length(); + let mut lengths = vec![0.0, len, -1.0, len + 1.0, -len, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN]; + lengths.extend(sp.anchors.iter().flat_map(|&a| [a, a - 1e-9, a + 1e-9])); + lengths.extend((0..=400).map(|k| len * k as f64 / 400.0)); + for s in lengths { + assert_eq!(sp.at_length(s), walked_at_length(&sp, s), "s = {s} on {:?}", sp.anchors); + } + } + } + + /// Finding the segment for an arc length on a spine of thousands of segments takes a few + /// comparisons, not one for each segment before it. + #[test] + fn spine_lookup_compares_a_few_segment_ends() { + let pts: Vec = (0..=4096).map(|i| Point::new(i as f64, if i % 2 == 0 { 0.0 } else { 1.0 })).collect(); + let sp = Spine::new(&PathData::single(SubPath::polyline(&pts, false))).unwrap(); + let n = sp.subpath.segment_count(); + assert_eq!(n, 4096); + for s in [sp.length() - 0.5, sp.length(), sp.length() / 2.0, 0.25] { + let mut compared = 0; + let i = segment_at(&sp.anchors, n, s, || compared += 1); + assert!(compared <= 13, "{compared} comparisons at {s}"); + assert_eq!(sp.at_length(s), walked_at_length(&sp, s)); + assert!(sp.anchors[i] <= s && (i + 1 == n || s <= sp.anchors[i + 1]), "segment {i} at {s}"); + } + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 39fe8cbc43..cf7a4d2fa0 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -568,23 +568,167 @@ mod tests { } } + /// The outline of a 200 × 100 box whose top side is `pieces`, end to end from (0, 0) to + /// (200, 0), and whose other sides are straight. + fn top_of_pieces(pieces: &[CubicBez]) -> PathData { + let pt = |p: Point| (p.x, p.y); + let mut knots: Vec = vec![]; + for (k, c) in pieces.iter().enumerate() { + let h_in = if k == 0 { pt(c.p0) } else { pt(pieces[k - 1].p2) }; + knots.push((pt(c.p0), h_in, pt(c.p1))); + } + let last = pieces.last().unwrap(); + knots.push(((200.0, 0.0), pt(last.p2), (200.0, 0.0))); + knots.extend([((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)), ((0.0, 100.0), (0.0, 100.0), (0.0, 100.0))]); + outline(&knots) + } + + /// A top curve whose parameter runs fast at its start and slow at its end. + const UNEVEN_TOP: CubicBez = + CubicBez { p0: Point::new(0.0, 0.0), p1: Point::new(120.0, -60.0), p2: Point::new(190.0, -30.0), p3: Point::new(200.0, 0.0) }; + #[test] - fn a_split_curve_moves_the_top_object_share_to_its_new_anchor() { - // Splitting the top curve at t = 0.3 keeps its shape but changes the polygon shares: the - // new anchor lands at the first piece's share, not at u = 0.3. - let top = CubicBez::new((0.0, 0.0), (40.0, -40.0), (160.0, -40.0), (200.0, 0.0)); - let (a, b) = (top.subsegment(0.0..0.3), top.subsegment(0.3..1.0)); - let path = outline(&[ - ((0.0, 0.0), (0.0, 0.0), a.p1.into()), - (a.p3.into(), a.p2.into(), b.p1.into()), - ((200.0, 0.0), b.p2.into(), (200.0, 0.0)), - ((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)), - ((0.0, 100.0), (0.0, 100.0), (0.0, 100.0)), - ]); - let c = Coons::from_top_object(&path, None).unwrap(); + fn a_split_curve_keeps_the_top_object_patch() { + // Splitting the top curve at t = 0.3, or into thirds, keeps its shape, and the patch stays + // the unsplit curve's: the new anchors land at u = 0.3, 1/3 and 2/3 (to within how finely + // the side is sampled), where the pieces' control polygons would have put them several + // points away. + let whole = Coons::from_top_object(&top_of_pieces(&[UNEVEN_TOP]), None).unwrap(); + for cuts in [vec![0.3], vec![1.0 / 3.0, 2.0 / 3.0]] { + let ends: Vec = std::iter::once(0.0).chain(cuts.iter().copied()).chain([1.0]).collect(); + let pieces: Vec = ends.windows(2).map(|w| UNEVEN_TOP.subsegment(w[0]..w[1])).collect(); + let c = Coons::from_top_object(&top_of_pieces(&pieces), None).unwrap(); + let total: f64 = pieces.iter().map(|p| polygon(*p)).sum(); + for (k, t) in cuts.iter().enumerate() { + let anchor = pieces[k].p3; + assert!(c.eval(*t, 0.0).distance(anchor) < 0.02, "{cuts:?}: u {t} → {:?}, want {anchor:?}", c.eval(*t, 0.0)); + let share: f64 = pieces[..=k].iter().map(|p| polygon(*p)).sum::() / total; + assert!(c.eval(share, 0.0).distance(anchor) > 2.0, "{cuts:?}: the polygon share {share} lands on the anchor"); + } + for (u, v) in [(0.1, 0.0), (0.3, 0.2), (0.5, 0.5), (0.62, 0.0), (0.9, 0.7), (0.97, 1.0)] { + let (p, q) = (c.eval(u, v), whole.eval(u, v)); + assert!(p.distance(q) < 1e-9, "{cuts:?}: ({u}, {v}) → {p:?}, unsplit {q:?}"); + } + } + } + + #[test] + fn a_split_curve_keeps_the_top_object_patch_when_split_at_a_cusp() { + // The top curve stops at t = 0.25 (a cusp), and an anchor splits it there: both handles at + // the split lie on the anchor, and the patch with the corners given stays the unsplit + // curve's. + let cusp = CubicBez::new((0.0, 0.0), (30.0, -40.0), (-58.0, 24.0), (200.0, 0.0)); + let pieces = [cusp.subsegment(0.0..0.25), cusp.subsegment(0.25..1.0)]; + assert!(pieces[0].p2.distance(pieces[0].p3) < 1e-9 && pieces[1].p1.distance(pieces[1].p0) < 1e-9); + let whole = Coons::from_top_object(&top_of_pieces(&[cusp]), Some([0, 1, 2, 3])).unwrap(); + let c = Coons::from_top_object(&top_of_pieces(&pieces), Some([0, 2, 3, 4])).unwrap(); + for (u, v) in [(0.1, 0.0), (0.25, 0.0), (0.3, 0.2), (0.5, 0.5), (0.62, 0.0), (0.9, 0.7), (0.97, 1.0)] { + let (p, q) = (c.eval(u, v), whole.eval(u, v)); + assert!(p.distance(q) < 1e-6, "({u}, {v}) → {p:?}, unsplit {q:?}"); + } + } + + #[test] + fn a_split_curve_keeps_the_top_object_patch_where_it_barely_moves_and_runs_straight() { + // A straight top curve that barely moves at t = 0.25 and doesn't bend there: split there, + // the handles at the split are a few thousandths of a point long and the pieces don't bend + // at all, and the patch with the corners given stays the unsplit curve's. + let slow = CubicBez::new((0.0, 0.0), (28.578125, 0.0), (-57.109375, 0.0), (200.0, 0.0)); + let pieces = [slow.subsegment(0.0..0.25), slow.subsegment(0.25..1.0)]; + let (into, out) = (pieces[0].p3.distance(pieces[0].p2), pieces[1].p0.distance(pieces[1].p1)); + assert!(into > 0.0 && out > 0.0 && into.max(out) < 0.01, "{into} {out}"); + let whole = Coons::from_top_object(&top_of_pieces(&[slow]), Some([0, 1, 2, 3])).unwrap(); + let c = Coons::from_top_object(&top_of_pieces(&pieces), Some([0, 2, 3, 4])).unwrap(); + for (u, v) in [(0.1, 0.0), (0.25, 0.0), (0.3, 0.2), (0.5, 0.5), (0.62, 0.0), (0.9, 0.7), (0.97, 1.0)] { + let (p, q) = (c.eval(u, v), whole.eval(u, v)); + assert!(p.distance(q) < 1e-6, "({u}, {v}) → {p:?}, unsplit {q:?}"); + } + } + + #[test] + fn a_top_object_joins_a_split_curve_saved_with_rounded_coordinates() { + // A file saves the pieces of a curve split into thirds, or into tenths, with its + // coordinates rounded to 0.001 pt, on an outline 5, 200 or 5,000 pt wide: the side is + // still that curve, to within 0.02 pt. + let round = |p: Point| Point::new((p.x * 1000.0).round() / 1000.0, (p.y * 1000.0).round() / 1000.0); + for cuts in [3u32, 10] { + let pieces: Vec = + (0..cuts).map(|k| UNEVEN_TOP.subsegment(f64::from(k) / f64::from(cuts)..f64::from(k + 1) / f64::from(cuts))).collect(); + for scale in [0.025, 1.0, 25.0] { + let mut split = top_of_pieces(&pieces); + split.transform(Affine::scale(scale)); + for a in split.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) { + (a.p, a.h_in, a.h_out) = (round(a.p), round(a.h_in), round(a.h_out)); + } + let mut whole = top_of_pieces(&[UNEVEN_TOP]); + whole.transform(Affine::scale(scale)); + let (c, whole) = (Coons::from_top_object(&split, None).unwrap(), Coons::from_top_object(&whole, None).unwrap()); + for (u, v) in [(1.0 / 6.0, 0.0), (1.0 / 3.0, 0.0), (0.45, 0.0), (0.5, 0.5), (2.0 / 3.0, 0.0), (0.9, 0.3)] { + let (p, q) = (c.eval(u, v), whole.eval(u, v)); + assert!(p.distance(q) < 0.02, "{cuts} pieces at {scale}x: ({u}, {v}) → {p:?}, unsplit {q:?}"); + } + } + } + } + + #[test] + fn a_top_object_keeps_smoothly_joined_curves_apart() { + // The top side's second piece leaves the split anchor in line with the first piece's + // handle, but its far handle is 1 pt off the split curve's: the pieces are two curves, and + // each takes the share of its control polygon. + let (a, b) = (UNEVEN_TOP.subsegment(0.0..0.3), UNEVEN_TOP.subsegment(0.3..1.0)); + let b = CubicBez::new(b.p0, b.p1, b.p2 + Vec2::new(0.0, 1.0), b.p3); + let c = Coons::from_top_object(&top_of_pieces(&[a, b]), None).unwrap(); let share = polygon(a) / (polygon(a) + polygon(b)); assert!(c.eval(share, 0.0).distance(a.p3) < 1e-9, "{:?}", c.eval(share, 0.0)); - assert!((share - 0.3).abs() > 0.01, "{share}"); + assert!((share - 0.3).abs() > 0.02, "{share}"); + } + + #[test] + fn a_top_object_side_partly_of_one_curve_keeps_the_polygon_shares() { + // The top side is a curve split at t = 0.3, then a straight segment: not all of it is one + // curve, so each piece keeps the share of its control polygon. + let curve = CubicBez::new((0.0, 0.0), (90.0, -45.0), (142.5, -22.5), (150.0, 0.0)); + let (a, b) = (curve.subsegment(0.0..0.3), curve.subsegment(0.3..1.0)); + let line = CubicBez::new((150.0, 0.0), (150.0 + 50.0 / 3.0, 0.0), (200.0 - 50.0 / 3.0, 0.0), (200.0, 0.0)); + let c = Coons::from_top_object(&top_of_pieces(&[a, b, line]), None).unwrap(); + let share = polygon(a) / (polygon(a) + polygon(b) + polygon(line)); + assert!(c.eval(share, 0.0).distance(a.p3) < 1e-9, "{:?}", c.eval(share, 0.0)); + // Joined into their curve, the two pieces would have put the anchor 0.3 of the way along + // their part of the side. + let joined = 0.3 * (polygon(a) + polygon(b)) / (polygon(a) + polygon(b) + polygon(line)); + assert!(c.eval(joined, 0.0).distance(a.p3) > 1.0, "{share} vs {joined}"); + } + + #[test] + fn a_top_object_side_of_many_arcs_stays_on_them() { + // A 120° arc of radius 500 drawn as `n` circular arcs, each close to a piece of one cubic + // while the whole arc is not one: the side stays on the circle, and the content's middle + // stays on the middle anchor, as the arcs' polygon shares put it. + let centre = Point::new(300.0, 600.0); + for n in [64u32, 128, 256] { + let step = 120f64.to_radians() / f64::from(n); + let reach = 500.0 * 4.0 / 3.0 * (step / 4.0).tan(); + let on = |a: f64| centre + Vec2::new(a.cos(), a.sin()) * 500.0; + let tangent = |a: f64| Vec2::new(-a.sin(), a.cos()) * reach; + let mut anchors: Vec = (0..=n) + .map(|i| { + let a = (-150f64).to_radians() + step * f64::from(i); + let p = on(a); + let h_in = if i == 0 { p } else { p - tangent(a) }; + let h_out = if i == n { p } else { p + tangent(a) }; + Anchor { p, h_in, h_out, ..Anchor::corner(p) } + }) + .collect(); + let (start, end) = (on((-150f64).to_radians()), on((-30f64).to_radians())); + anchors.extend([Anchor::corner(Point::new(end.x, 800.0)), Anchor::corner(Point::new(start.x, 800.0))]); + let path = PathData::single(SubPath::new(anchors, true)); + let c = Coons::from_top_object(&path, Some([0, n, n + 1, n + 2])).unwrap(); + let off = (0..=4000).map(|k| (c.eval(f64::from(k) / 4000.0, 0.0).distance(centre) - 500.0).abs()).fold(0.0, f64::max); + assert!(off < 0.001, "{n} arcs: the side lies {off} pt off the circle"); + let mid = c.eval(0.5, 0.0); + assert!(mid.distance(Point::new(300.0, 100.0)) < 0.001, "{n} arcs: the middle lands at {mid:?}"); + } } #[test] diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index d912479464..b6d5bbc88d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -147,14 +147,18 @@ impl Coons { /// The patch of a top object's outline (the largest subpath of `path`), split into four sides /// at its corners. Each piece of a side between two anchors or corners takes a share of `u` or /// `v` in proportion to the length of its control polygon, spread along the piece by its own - /// curve parameter. A segment without handles counts as a cubic with its handles a third of - /// the way along, so the content spreads evenly along it. `corners` are the anchors (indices - /// into the outline, in its own order) at the top left, top right, bottom right and bottom - /// left, as a saved file gives them. They stay on those anchors when they run the way the - /// outline does or the opposite way, so a reflected outline keeps them; without them, or when - /// they don't fit the outline, each corner is found from the outline ([`top_object_corner`]). - /// A straight segment closes an open subpath, and segments of no length are left out. A top - /// object's envelope maps onto this patch. + /// curve parameter. A side whose pieces, from corner to corner, are all one cubic curve split at + /// anchors (each within 0.02 pt of that curve's piece) runs along that curve instead, by its + /// parameter: splitting the curve of such a side at more anchors, without changing its shape, + /// leaves how the side shares out the content as it was (corners found from the outline can + /// still move). A segment without handles counts as a cubic with its handles a third of the way + /// along, so the content spreads evenly along it. `corners` are the anchors (indices into the + /// outline, in its own order) at the top left, top right, bottom right and bottom left, as a + /// saved file gives them. They stay on those anchors when they run the way the outline does or + /// the opposite way, so a reflected outline keeps them; without them, or when they don't fit + /// the outline, each corner is found from the outline ([`top_object_corner`]). A straight + /// segment closes an open subpath, and segments of no length are left out. A top object's + /// envelope maps onto this patch. pub fn from_top_object(path: &PathData, corners: Option<[u32; 4]>) -> Option { let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?; let mut sp = sp.clone(); @@ -212,11 +216,12 @@ impl Coons { segs.get(i).map_or(Point::ZERO, |(c, _)| c.eval((s - i as f64).clamp(0.0, 1.0))) }; // The side from `a` to `b`: its points, and where along it (0 to 1) each lies. It runs - // through pieces of segments, split at `a` and `b`. Each piece takes its share of the side - // by the length of its control polygon and holds points at even steps of its parameter - // (at least 16, and at least `SIDE` points on the side), its ends included. A side of - // many pieces takes fewer points in each, so it holds at most `MAX_SIDE_POINTS` points, - // or one for each piece when it has more pieces than that. + // through pieces of segments, split at `a` and `b`; when all of them are one curve split + // at anchors ([`one_curve`]), it runs along that curve instead. Each piece takes its share + // of the side by the length of its control polygon and holds points at even steps of its + // parameter (at least 16, and at least `SIDE` points on the side), its ends included. A + // side of many pieces takes fewer points in each, so it holds at most `MAX_SIDE_POINTS` + // points, or one for each piece when it has more pieces than that. let side = |a: f64, b: f64| { let span = (b - a).rem_euclid(n); if !(span.is_finite() && span > 0.0) { @@ -232,8 +237,11 @@ impl Coons { } s = next; } - let polygon = |c: &CubicBez| c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3); - let mut weights: Vec = pieces.iter().map(polygon).collect(); + let pieces = match one_curve(&pieces) { + Some(one) => vec![one], + None => pieces, + }; + let mut weights: Vec = pieces.iter().map(control_polygon).collect(); let mut total: f64 = weights.iter().sum(); if !(total.is_finite() && total > 0.0) { weights.iter_mut().for_each(|w| *w = 1.0); @@ -336,6 +344,76 @@ impl Coons { /// anchors. const MAX_SIDE_POINTS: usize = 1 << 16; +/// How far, in points, each control point of each piece of a top object's side may lie from the +/// matching piece of one cubic curve for the side to run along that curve ([`one_curve`]); the side +/// then lies within this of the pieces it is drawn with. A curve split into thirds or tenths and +/// saved with its coordinates rounded to 0.001 pt stays within it, on outlines from 5 to 5,000 pt +/// wide; coarser rounding, or a piece of a much smaller share of the curve, can take it past this. +/// A smooth side that isn't one curve, such as an arc of a circle drawn in many pieces, lies +/// further off as a whole, even where each pair of its pieces would not. +const ONE_CURVE: f64 = 0.02; + +/// The length of `c`'s control polygon. +fn control_polygon(c: &CubicBez) -> f64 { + c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3) +} + +/// The cubic curve that `pieces`, two or more end to end, are all pieces of, split at the anchors +/// between them, when each control point of each piece lies within [`ONE_CURVE`] of the matching +/// piece of that curve; `None` otherwise. Split at parameter `t`, a curve's handles on either side +/// of the split lie in line, their lengths in proportion to the spans of its parameter the two +/// pieces take; split where the curve stops (a cusp), both handles lie on the anchor, and the +/// pieces' second derivatives there, in proportion to the squares of the spans, give the proportion +/// instead (whichever of the two pairs is the longer). Those proportions give each piece's span, and the curve follows from the first +/// piece's start and its handle, and the last piece's end and its handle, scaled up by their spans. +/// Every piece is then checked against that curve's piece over its span, so error can't build up +/// from one split to the next (the first piece's start and handle, and the last piece's end and +/// handle, match by construction). +fn one_curve(pieces: &[CubicBez]) -> Option { + let (first, last) = (pieces.first()?, pieces.last()?); + if pieces.len() < 2 { + return None; + } + let mut spans = Vec::with_capacity(pieces.len()); + spans.push(1.0); + for pair in pieces.windows(2) { + let [a, b] = pair else { return None }; + let (into, out) = (a.p3.distance(a.p2), b.p0.distance(b.p1)); + // How far each piece bends away from the anchor goes with the squares of the spans. Split + // where the curve stops (a cusp), both handles lie on the anchor and say nothing of the + // spans, and the bends say it; where the curve runs straight through the split, the bends + // are nothing and the handles say it. Whichever pair is the longer is the one to go by. + let bend_in = ((a.p3 - a.p2) - (a.p2 - a.p1)).hypot(); + let bend_out = ((b.p2 - b.p1) - (b.p1 - b.p0)).hypot(); + let ratio = if into.min(out) >= bend_in.min(bend_out) { out / into } else { (bend_out / bend_in).sqrt() }; + let next = spans.last().copied().unwrap_or(1.0) * ratio; + if !(next.is_finite() && next > 0.0) { + return None; + } + spans.push(next); + } + let total: f64 = spans.iter().sum(); + let (head, tail) = (spans.first().copied().unwrap_or(1.0) / total, spans.last().copied().unwrap_or(1.0) / total); + if !(total.is_finite() && head > 0.0 && tail > 0.0) { + return None; + } + let one = CubicBez::new(first.p0, first.p0 + (first.p1 - first.p0) / head, last.p3 + (last.p2 - last.p3) / tail, last.p3); + if ![one.p1, one.p2].iter().all(|p| p.is_finite()) { + return None; + } + let near = |p: Point, q: Point| p.distance(q) <= ONE_CURVE; + let mut from = 0.0; + for (k, (piece, span)) in pieces.iter().zip(&spans).enumerate() { + let to = if k + 1 == pieces.len() { 1.0 } else { (from + span / total).min(1.0) }; + let part = one.subsegment(from..to); + if !(near(part.p0, piece.p0) && near(part.p1, piece.p1) && near(part.p2, piece.p2) && near(part.p3, piece.p3)) { + return None; + } + from = to; + } + Some(one) +} + /// How far into the outline's bounding box, as a share of its width and of its height, a corner /// of a top object looks for an anchor. const CORNER_REACH: f64 = 0.475; diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 2cf260cbec..dde6621993 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -87,6 +87,8 @@ pub struct SymbolBoxes<'a> { sliced_placed: Cell, /// The most objects of symbol art [`Self::sliced_visual`] places: [`MAX_SYMBOL_NODES`]. sliced_most: u64, + /// The widths [`Self::measured_width`] has measured, by kind and symbol name. + widths: RefCell>>, } impl Document { @@ -107,6 +109,7 @@ impl Document { sliced: RefCell::default(), sliced_placed: Cell::new(0), sliced_most: MAX_SYMBOL_NODES, + widths: RefCell::default(), } } } @@ -247,6 +250,18 @@ impl<'a> SymbolBoxes<'a> { self.art(name)?.brush } + /// The width `measure` gives for the symbol named `name`, measured once for these boxes for each + /// `kind`, which names what `measure` measures. `measure` may call this again for other names. + pub fn measured_width(&self, kind: &'static str, name: &str, measure: impl FnOnce() -> Option) -> Option { + let key = (kind, name.to_string()); + if let Some(w) = self.widths.borrow().get(&key) { + return *w; + } + let w = measure(); + self.widths.borrow_mut().insert(key, w); + w + } + /// How far the visual box of an instance of the symbol named `name` grows in boxes made by /// [`Self::with_brush_art`]: `16 × max(w, 1) + 8` for the widest stroke `w` that /// [`Self::brush_width`] finds, as far as the renderer grows the cull bounds of a brushed path diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 2b4953d151..b77f804de7 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -424,6 +424,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { if !kept { return None; } + // The members' own effects run first, as on the canvas. + crate::group::members_as_drawn(Some(d), &mut m); reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 959bd5e857..a6581fa449 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -209,6 +209,8 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nod Some(area) => Some(reshaped(&area, n, doc)), None => { let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes); + // The members' own effects run first, as on the canvas. + members_as_drawn(doc, &mut m); let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc); // The group's own fills and strokes follow the effects as its members do. if carry.moves(&m.appearance) { @@ -225,6 +227,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nod let art = h(n).filter(|art| nodes.charge(art))?; let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); crate::reshape::outline_nested_text(doc, &mut art, false); + members_as_drawn(doc, &mut art); let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc); // The art's own fills and strokes follow the effects as its members do. if carry.moves(&art.appearance) { @@ -754,7 +757,8 @@ fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, hidden: bool, lo } /// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects -/// reshaping all its members as one piece (the whole art's bounds as their reference box), and its +/// reshaping all its members as one piece (the whole art's bounds as their reference box) after +/// the members' own geometry and Pathfinder effects ([`members_as_drawn`]), and its /// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below /// the members (after a clipping path) or above them, as its contents slot says. Its /// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live @@ -790,6 +794,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); + members_as_drawn(doc, &mut m); let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc); // The group's own fills and strokes paint the members as a whole: their gradients follow // the effects as the members' do. @@ -821,5 +826,83 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { Some(m) } +/// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every +/// depth of its groups and layers, by the art it draws ([`evaluate_container`], +/// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members +/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the +/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with +/// everything in it. Calls nested in one another, through the +/// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members +/// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at +/// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are. +pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) { + let Some(_frame) = DrawnFrame::enter() else { return }; + let clip = usize::from(n.clips()); + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut().skip(clip).filter(|c| c.visible) { + if !DrawnFrame::take_member() { + return; + } + // A group or layer with a 3D effect stays whole: the geometry effects listed after its 3D + // effect reshape its 3D art. + if is_container(c) && (crate::has_solid(c) || crate::has_revolve(c)) { + continue; + } + let art = if !is_container(c) { + crate::reshape::own_effects_art(doc, c) + } else if has_pathfinder(c) || !crate::reshape::geometry_effects(c).is_empty() { + evaluate_container(doc, c) + } else { + None + }; + match art { + Some(a) => *c = Arc::new(a), + None if is_container(c) => members_as_drawn(doc, Arc::make_mut(c)), + None => {} + } + } +} + +thread_local! { + /// How many [`members_as_drawn`] calls are running, one inside another, and how many members + /// they have visited since the outermost one started. + static DRAWN: std::cell::Cell<(usize, u64)> = const { std::cell::Cell::new((0, 0)) }; +} + +/// One running [`members_as_drawn`] call, counted in [`DRAWN`] until it is dropped. +struct DrawnFrame; + +impl DrawnFrame { + /// Count one more call; `None` when [`vectorcraft_doc::depth::MAX_DEPTH`] are running. The + /// outermost call starts the count of members again. + fn enter() -> Option { + let (frames, members) = DRAWN.with(|d| d.get()); + if frames >= vectorcraft_doc::depth::MAX_DEPTH { + return None; + } + DRAWN.with(|d| d.set((frames + 1, if frames == 0 { 0 } else { members }))); + Some(DrawnFrame) + } + + /// Count one more member visited; `false` once [`crate::SYMBOL_ART_MAX_NODES`] have been. + fn take_member() -> bool { + let (frames, members) = DRAWN.with(|d| d.get()); + if members >= crate::SYMBOL_ART_MAX_NODES { + return false; + } + DRAWN.with(|d| d.set((frames, members + 1))); + true + } +} + +impl Drop for DrawnFrame { + fn drop(&mut self) { + DRAWN.with(|d| { + let (frames, members) = d.get(); + d.set((frames.saturating_sub(1), members)); + }); + } +} + #[cfg(test)] mod tests; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index c169c807b2..c6e2a78c73 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -555,6 +555,298 @@ fn a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_dist assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}"); } +/// The bounds of what `n` draws, as the renderer draws it: a group or layer with an appearance of +/// its own as the art [`evaluate_container`] makes of it, and each visible path and compound path +/// with the geometry effects still on it applied, then those of each visible fill and stroke that +/// has some. +fn drawn_bounds(n: &Node) -> Option { + if !n.visible { + return None; + } + if let Some(m) = evaluate_container(None, n) { + return drawn_bounds(&m); + } + if let Some(ch) = n.children() + && !matches!(n.kind, NodeKind::Compound { .. }) + { + return ch.iter().filter_map(|c| drawn_bounds(c)).reduce(|a, b| a.union(b)); + } + let (path, _) = crate::reshape::leaf_geometry(n)?; + let geometry = |fx: &[Effect]| -> Vec { fx.iter().filter(|e| e.visible && crate::is_geometry(&e.id)).cloned().collect() }; + let apply = |fx: &[Effect], p: &PathData| crate::apply_geometry(&geometry(fx), p, p.bounds().unwrap()); + let g = apply(&n.appearance.effects, &path); + let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i) && !geometry(i.effects()).is_empty()).collect(); + if items.is_empty() { + return g.bounds(); + } + items.iter().filter_map(|i| apply(i.effects(), &g).bounds()).reduce(|a, b| a.union(b)) +} + +fn black() -> Appearance { + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0) +} + +/// A black 100 pt square from (100, 100). +fn square_at_100() -> Node { + Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), black()) +} + +fn effect(id: &str, params: serde_json::Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn offset_10() -> Effect { + effect("path.offsetPath", serde_json::json!({"offset": 10})) +} + +fn scaled(s: f64) -> Effect { + effect("distort.transform", serde_json::json!({"scaleH": s, "scaleV": s})) +} + +fn with_effect(mut n: Node, e: Effect) -> Node { + n.appearance.effects.push(e); + n +} + +/// The width that a group with `e` holding `member` draws at. +fn drawn_width(member: Node, e: Effect) -> f64 { + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(member)]), e); + drawn_bounds(&g).unwrap().width() +} + +/// A member's own Offset Path runs before its group's Transform: the square offset by 10 pt to +/// 120 pt, then scaled by half, is 60 pt wide (70 pt the other way round). +#[test] +fn a_members_own_offset_runs_before_its_groups_scale() { + let w = drawn_width(with_effect(square_at_100(), offset_10()), scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A member's own Transform runs before its group's Offset Path: the square scaled to 200 pt, then +/// offset by 10 pt, is 220 pt wide (240 pt the other way round). +#[test] +fn a_members_own_scale_runs_before_its_groups_offset() { + let w = drawn_width(with_effect(square_at_100(), scaled(200.0)), offset_10()); + assert!((w - 220.0).abs() < 1e-6, "{w}"); +} + +/// A member inside a plain group inside the group takes its own effects first too. +#[test] +fn a_member_of_a_plain_nested_group_takes_its_own_effects_first() { + let w = drawn_width(Node::group(NodeId(5), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A nested group's own geometry effects run before the outer group's. +#[test] +fn a_nested_groups_own_effects_run_before_its_groups() { + let w = drawn_width(with_effect(Node::group(NodeId(5), vec![Arc::new(square_at_100())]), offset_10()), scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A nested group's Pathfinder effect runs before the outer group's geometry effects: the back +/// square minus the front one is 50 pt wide, and offset by 10 pt it is 70 pt (offsetting the +/// squares first leaves 50 pt). +#[test] +fn a_nested_groups_pathfinder_runs_before_its_groups_effects() { + let back = square_at_100(); + let front = Node::path(NodeId(2), shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0)), black()); + let nested = with_effect(Node::group(NodeId(5), vec![Arc::new(back), Arc::new(front)]), effect("pathfinder.subtract", serde_json::json!({}))); + let w = drawn_width(nested, offset_10()); + assert!((w - 70.0).abs() < 1e-6, "{w}"); +} + +/// The geometry effects of a member's own fill run before its group's: 60 pt, as for the +/// member's own effect. +#[test] +fn a_members_fill_effects_run_before_its_groups() { + let mut member = square_at_100(); + member.appearance.items[0].effects_mut().push(offset_10()); + let w = drawn_width(member, scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A member whose fills have geometry effects stays one element of a knockout group: its fills do +/// not knock each other out. +#[test] +fn a_member_drawn_as_its_fills_stays_one_knockout_element() { + let mut member = square_at_100(); + member.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))))); + member.appearance.items[0].effects_mut().push(offset_10()); + let mut g = with_effect(Node::group(NodeId(10), vec![Arc::new(member)]), scaled(50.0)); + g.knockout = vectorcraft_doc::Knockout::On; + let art = evaluate_container(None, &g).unwrap(); + let elements = Node::knockout_elements(art.children().unwrap()); + assert_eq!(elements.len(), 1, "{:?}", elements.iter().map(|e| (e.id, &e.kind)).collect::>()); +} + +/// The art a Pathfinder operation or compound shape takes from a group with geometry effects has +/// the members' own effects applied first, as the canvas draws it. +#[test] +fn shape_art_of_a_group_applies_its_members_effects_first() { + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0)); + let art = shape_art(None, &g, None).unwrap(); + let w = drawn_bounds(&art).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "{w}"); + let leaves = member_leaves(None, &g, None); + let w = leaves.iter().filter_map(drawn_bounds).reduce(|a, b| a.union(b)).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "leaves: {w}"); +} + +/// A symbol instance's own geometry effects run after those of its symbol's members, as the canvas +/// draws it and in the art a Pathfinder operation takes from it. +#[test] +fn a_symbol_instances_effects_run_after_its_symbols_members_own() { + let art = Node::group(NodeId(20), vec![Arc::new(with_effect(square_at_100(), offset_10()))]); + let instance = + with_effect(Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }), scaled(50.0)); + let drawn = crate::reshape(None, &instance, Some(&art)).unwrap(); + let w = drawn_bounds(&drawn).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "drawn: {w}"); + let hook = move |_: &Node| Some(art.clone()); + let w = drawn_bounds(&shape_art(None, &instance, Some(&hook)).unwrap()).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "shape art: {w}"); +} + +/// Export bakes the group as the canvas draws it. +#[test] +fn a_baked_group_applies_its_members_effects_first() { + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0)); + d.insert(Some(layer), 0, g).unwrap(); + let baked = crate::bake_document(&d).unwrap(); + let w = baked.node(NodeId(10)).unwrap().geometric_bounds().unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A group whose Transform effect only moves and copies keeps its type when baked; a nested +/// group's own Transform still runs before the copies: the nested square scaled by half to 50 pt +/// and copied 200 pt to the right spans 250 pt (copying first, then scaling both copies about +/// their middle, spans 150 pt). +#[test] +fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() { + if skip_without_craft_fonts() { + return; + } + let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let text = Node::new(NodeId(3), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style)))); + let nested = with_effect(Node::group(NodeId(5), vec![Arc::new(square_at_100())]), scaled(50.0)); + let g = with_effect( + Node::group(NodeId(10), vec![Arc::new(text), Arc::new(nested)]), + effect("distort.transform", serde_json::json!({"moveH": 200, "copies": 1})), + ); + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, g).unwrap(); + let baked = crate::bake_document(&d).unwrap(); + let mut text = 0; + baked.walk(|n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(text, 2, "the type stays type, copied"); + let w = baked.node(NodeId(5)).unwrap().geometric_bounds().unwrap().width(); + assert!((w - 250.0).abs() < 1e-6, "{w}"); +} + +/// A clip group's clipping path keeps its own effects; the clipped members take theirs first. +#[test] +fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { + let mut clip = with_effect(square_at_100(), offset_10()); + clip.id = NodeId(2); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let clipped = + Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(with_effect(square_at_100(), offset_10()))], clip: true }); + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(clipped)]), scaled(50.0)); + let art = evaluate_container(None, &g).unwrap(); + let clip_group = art.children().unwrap()[0].clone(); + let ch = clip_group.children().unwrap(); + assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind); + assert_eq!(crate::reshape::geometry_effects(&ch[0]).len(), 1, "the clipping path keeps its Offset Path"); + assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art"); +} + +/// A member with a linked opacity mask keeps its own effects on it, so its mask moves with it +/// under the group's Transform; so does a member with a 3D effect, whose later geometry effects +/// apply to its 3D art. +#[test] +fn members_with_linked_masks_or_3d_effects_keep_their_own_effects() { + let mut masked = with_effect(square_at_100(), offset_10()); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new( + Node::path(NodeId(2), shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), black()), + true, + ))); + let mut solid = with_effect(square_at_100(), effect(crate::solids::EXTRUDE, serde_json::json!({}))); + solid.id = NodeId(3); + solid.appearance.effects.push(scaled(50.0)); + let moved = effect("distort.transform", serde_json::json!({"moveH": 50, "strokes": true})); + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(masked), Arc::new(solid)]), moved); + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert!(matches!(ch[0].kind, NodeKind::Path { .. }) && crate::reshape::geometry_effects(&ch[0]).len() == 1, "{:?}", ch[0].kind); + let mask = ch[0].extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap(); + assert!((mask.x0 - 150.0).abs() < 1e-6, "the mask moves with the square: {mask:?}"); + assert!(matches!(ch[1].kind, NodeKind::Path { .. }) && ch[1].appearance.effects.len() == 2, "{:?}", ch[1].kind); +} + +/// A nested group with a 3D effect stays as it is, with its members: the Transform listed after +/// its Extrude still applies to its 3D art. +#[test] +fn a_nested_group_with_a_3d_effect_keeps_its_own_effects() { + let inner = with_effect(square_at_100(), offset_10()); + let mut solid = with_effect(Node::group(NodeId(5), vec![Arc::new(inner)]), effect(crate::solids::EXTRUDE, serde_json::json!({}))); + solid.appearance.effects.push(scaled(50.0)); + let moved = effect("distort.transform", serde_json::json!({"moveH": 50})); + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(solid)]), moved); + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert!( + matches!(ch[0].kind, NodeKind::Group { .. }) && ch[0].id == NodeId(5) && ch[0].appearance.effects.len() == 2, + "{:?}", + ch[0].appearance.effects + ); + let member = &ch[0].children().unwrap()[0]; + assert!(matches!(member.kind, NodeKind::Path { .. }) && crate::reshape::geometry_effects(member).len() == 1, "{:?}", member.kind); +} + +/// Members nested deeper than the depth limit keep their own effects as they are. +#[test] +fn members_past_the_depth_limit_stay_as_they_are() { + let mut n = with_effect(square_at_100(), offset_10()); + for i in 0..vectorcraft_doc::depth::MAX_DEPTH { + n = Node::group(NodeId(100 + i as u64), vec![Arc::new(n)]); + } + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(n)]), scaled(50.0)); + let art = evaluate_container(None, &g).unwrap(); + let mut deepest = None; + art.walk(&mut |c: &Node| { + if c.id == NodeId(1) { + deepest = Some(c.clone()); + } + }); + let deepest = deepest.unwrap(); + assert!(matches!(deepest.kind, NodeKind::Path { .. }), "{:?}", deepest.kind); + assert_eq!(crate::reshape::geometry_effects(&deepest).len(), 1); +} + +/// The depth limit counts nested groups with effects of their own too. +#[test] +fn nested_groups_with_effects_count_toward_the_depth_limit() { + let mut n = with_effect(square_at_100(), offset_10()); + for i in 0..vectorcraft_doc::depth::MAX_DEPTH { + n = with_effect(Node::group(NodeId(100 + i as u64), vec![Arc::new(n)]), effect("distort.transform", serde_json::json!({"moveH": 1}))); + } + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(n)]), scaled(50.0)); + let art = evaluate_container(None, &g).unwrap(); + let mut deepest = None; + art.walk(&mut |c: &Node| { + if c.id == NodeId(1) && matches!(c.kind, NodeKind::Path { .. }) { + deepest = Some(c.clone()); + } + }); + assert!(deepest.is_some_and(|d| crate::reshape::geometry_effects(&d).len() == 1), "the deepest member keeps its effect"); +} + /// An image or a symbol instance that a group's Transform effect moves takes the placed gradients /// of its own fills along, as a path does, and each copy takes them as many times as it takes the /// transform. diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 2bda0aa88f..c5aaed7309 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -524,9 +524,10 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// `n` as plain art that geometry effects can reshape, without its transparency and object /// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by -/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), its -/// fills and strokes carrying the geometry effects of the instance's active fill and stroke -/// ([`add_instance_row_effects`]). +/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), as +/// the art its members draw when the instance has geometry effects of its own +/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry effects of the +/// instance's active fill and stroke ([`add_instance_row_effects`]). /// `None` for other kinds (images, paths, containers), and for an instance whose art placed /// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of /// `doc`'s symbols that its instances show toward its instance budget. @@ -562,20 +563,16 @@ fn outline_art_with( Node::group(n.id, vec![Arc::new(a)]) } // The object as the one piece the copies of its Transform effect are made from. - _ if has_transform_art(n) || (apart && has_transform_copies(n)) => { - let mut one = n.clone(); - one.opacity = 1.0; - one.blend = Default::default(); - one.isolate = false; - one.extras_mut().mask = None; - one.appearance.effects.clear(); - Node::group(n.id, vec![Arc::new(one)]) - } + _ if has_transform_art(n) || (apart && has_transform_copies(n)) => one_piece(n), _ if vectorcraft_doc::live::is_live(n) => evaluated_with(symbols, n, outline, nodes), _ => return None, }; outline_nested_text(paint_doc, &mut art, false); if matches!(n.kind, NodeKind::SymbolInstance { .. }) { + // The instance's own geometry effects reshape what the symbol's members draw. + if !geometry_effects(n).is_empty() { + crate::group::members_as_drawn(paint_doc, &mut art); + } add_instance_row_effects(symbols, n, &mut art); } art.opacity = 1.0; @@ -843,18 +840,64 @@ fn as_art_opts( } // The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip // group). - let mut out = art; - out.name = n.name.clone(); - out.visible = n.visible; - out.opacity = n.opacity; - out.blend = n.blend; - out.isolate = n.isolate; + Some(dressed(art, n)) +} + +/// `art`, the art made of `n`, with `n`'s name, visibility, transparency, opacity mask and +/// remaining (raster) effects. +fn dressed(mut art: Node, n: &Node) -> Node { + art.name = n.name.clone(); + art.visible = n.visible; + art.opacity = n.opacity; + art.blend = n.blend; + art.isolate = n.isolate; // One element: its pieces never knock each other out. - out.knockout = vectorcraft_doc::Knockout::Off; - out.knockout_shape = n.knockout_shape; - out.extras_mut().mask = n.extras().mask.clone(); - out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); - Some(out) + art.knockout = vectorcraft_doc::Knockout::Off; + art.knockout_shape = n.knockout_shape; + art.extras_mut().mask = n.extras().mask.clone(); + art.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); + art +} + +/// A group with `n`'s id holding `n` without its transparency, opacity mask and effects: the one +/// piece that `n`'s own geometry effects reshape. +fn one_piece(n: &Node) -> Node { + let mut one = n.clone(); + one.opacity = 1.0; + one.blend = Default::default(); + one.isolate = false; + one.extras_mut().mask = None; + one.appearance.effects.clear(); + Node::group(n.id, vec![Arc::new(one)]) +} + +/// Path or compound path `n` with its own geometry effects applied, as the art it draws: with +/// geometry effects in a visible fill or stroke of its own, a group of one path for each fill and +/// stroke, its effects and that fill's or stroke's applied ([`crate::bake_type_appearance`]); +/// otherwise a group with `n`'s id holding the path reshaped by its effects ([`reshape_leaves`]). +/// The art keeps `n`'s id, name, visibility, transparency, opacity mask and raster effects. `None` +/// for a guide, for other kinds of objects, for a path with a linked opacity mask or a 3D effect, +/// and when `n` has no visible geometry effect of its own or in a visible fill or stroke. +pub(crate) fn own_effects_art(doc: Option<&Document>, n: &Node) -> Option { + if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) + || n.extras().mask.as_ref().is_some_and(|m| m.linked) + || crate::has_solid(n) + || crate::has_revolve(n) + { + return None; + } + if n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())) { + // One element: its pieces never knock each other out. + return crate::bake_type_appearance(doc, n).map(|art| Node { knockout: vectorcraft_doc::Knockout::Off, ..art }); + } + let fx = geometry_effects(n); + if fx.is_empty() { + return None; + } + let mut art = one_piece(n); + reshape_leaves(&mut art, &fx, &n.appearance.effects, doc); + transform_items(&mut art, doc); + Some(dressed(art, n)) } /// Map every path under `art` (and its gradients), and every path in the art of the linked opacity diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index e0c7bc5130..aead8b72ce 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -523,11 +523,12 @@ fn a_groups_transform_applies_after_its_members_own() { let mut on_fill = fill_square(33, vec![shadow(10.0, 0.0, 0.0)]); on_fill.appearance.effects = vec![turn()]; let art = crate::evaluate_container(None, &group(32, vec![on_fill], vec![uneven()])).unwrap(); - assert!(near(&[fill_shadow(&node_in(&art, 33))], &[want]), "fill: {:?}", fill_shadow(&node_in(&art, 33))); + // The member is drawn as its art: a group with its id holding the turned square. + let filled = node_in(&art, 33).children().and_then(|ch| ch.first().map(|c| (**c).clone())).unwrap(); + assert!(near(&[fill_shadow(&filled)], &[want]), "fill: {:?}", fill_shadow(&filled)); let nested = group(34, vec![group(35, vec![square(36, vec![shadow(10.0, 0.0, 0.0)])], vec![turn()])], vec![uneven()]); let art = crate::evaluate_container(None, &nested).unwrap(); - let inner = crate::evaluate_container(None, &node_in(&art, 35)).unwrap(); - assert!(near(&shadows_in(&inner), &[want]), "nested: {:?}", shadows_in(&inner)); + assert!(near(&shadows_in(&art), &[want]), "nested: {:?}", shadows_in(&art)); } /// An object's Transform effect transforms the raster effects of its fills and strokes with the @@ -645,7 +646,8 @@ fn a_groups_transform_moves_its_members_fills_shadows_beside_their_own_transform let art = crate::evaluate_container(None, &g).unwrap(); for (id, _, want) in &cases { let member = node_in(&art, *id); - let drawn = shadows_in(&crate::reshape(None, &member, None).unwrap()); + // The member is drawn as its art, its fill's own Transform effect applied. + let drawn = shadows_in(&member); assert!(!drawn.is_empty() && near(&drawn, &vec![*want; drawn.len()]), "Scale Strokes & Effects {strokes}: {id} {drawn:?}"); } } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index fb86fa7852..afe86769db 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1365,17 +1365,19 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke); let source_has = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind); let source_shows = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); + // A whole pick replaces the stack: it removes the rows of a kind the source has no row of. + let whole = fill == FillAttrs::ALL && stroke == StrokeAttrs::ALL; let (paints, opacities) = if append { let rows = (fill.color && source_shows(true), stroke.color && source_shows(false)); (rows, rows) } else { ( (fill.color, stroke.color || (stroke.weight && source_has(false))), - (fill.transparency && source_has(true), stroke.transparency && source_has(false)), + (fill.transparency && (whole || source_has(true)), stroke.transparency && (whole || source_has(false))), ) }; // A pick of some attributes edits the topmost row of its kind, which may be hidden; `append` and a whole pick write rows. - let rows_written = append || (fill == FillAttrs::ALL && stroke == StrokeAttrs::ALL); + let rows_written = append || whole; let top_shows = |id: NodeId, kind: bool| { let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(|it| it.visible()) @@ -1389,6 +1391,9 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { .collect() }; let mut opacity_rows = top_opacity_changed(&before, d, &targets); + // A written row that had paint before the pick counts too when the pick left it without paint, as in + // `capture_series_writes`. + opacity_rows.extend(painted_rows(&before, written(opacities))); opacity_rows.extend(painted_rows(d, written(opacities))); let mut rows = top_paint_changed(&before, d, &targets); rows.extend(painted_rows(d, written(paints))); diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index d63bdaea77..0ac4fc8d6b 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -8,10 +8,22 @@ use super::{ // ---------- Smooth ---------- -/// Refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. -fn refit_run(anchors: &[Anchor], tol: f64) -> Vec { +/// The spread (points) of the Gaussian the Smooth tool smooths a path along its length with before +/// refitting it, as a share of the tool's reach: a third, so each point is averaged with the stretch +/// of path within the reach on either side of it. VectorCraft's own value. +const SMOOTH_SPREAD: f64 = 1.0 / 3.0; + +/// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of +/// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the +/// smoothing moved it, keeping a corner only where the path turns back on itself. +fn refit_options(tol: f64, sigma: f64) -> SimplifyOptions { + SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, smooth: sigma, smooth_moved_only: true } +} + +/// Smooth and refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. +fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec { let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); - let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, ..Default::default() }); + let fitted = po::simplify_with(&piece, &refit_options(tol, sigma)); let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; let (f, l) = (anchors[0], anchors[anchors.len() - 1]); let last = out.len() - 1; @@ -25,7 +37,7 @@ fn refit_run(anchors: &[Anchor], tol: f64) -> Vec { out } -fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Option { +fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Option { let n = sp.anchors.len(); let marked: Vec = sp.anchors.iter().map(|a| near(a.p)).collect(); if n < 3 || !marked.iter().any(|m| *m) { @@ -33,11 +45,8 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio } if sp.closed { let Some(k) = marked.iter().position(|m| !m) else { - // Everything is in range: smooth the whole closed path. - let f = po::simplify_with( - &PathData::single(sp.clone()), - &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, ..Default::default() }, - ); + // Everything is in range: smooth the whole closed path, across its start too. + let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma)); return f.subpaths.into_iter().next(); }; // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. @@ -48,14 +57,14 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio open.anchors.push(open.anchors[0]); m.push(false); open.closed = false; - let mut res = smooth_open(&open.anchors, &m, near, tol); + let mut res = smooth_open(&open.anchors, &m, near, tol, sigma); let l = res.pop()?; if let Some(r0) = res.first_mut() { r0.h_in = l.h_in; } return Some(SubPath::new(res, true)); } - Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false)) + Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma), false)) } /// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it @@ -84,10 +93,11 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez (hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0))) } -/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments -/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under -/// it, and the rest of a long segment next to it stays as it was (#1512). -fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec { +/// `anchors` with each run of `marked` ones smoothed by a Gaussian of spread `sigma` and refitted +/// within `tol`, together with the stretch of the segments leading into the run that is in the +/// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next +/// to it stays as it was (#1512). +fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Vec { let n = anchors.len(); let mut runs = vec![]; let mut i = 0; @@ -127,7 +137,7 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool head = Some(Anchor { h_out: far.p1, ..*outer }); *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); } - let new = refit_run(&piece, tol); + let new = refit_run(&piece, tol, sigma); out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); } out @@ -138,6 +148,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { let pts = points_param(p, "points", C)?; let radius = number_or(p, "radius", 12.0, C)?.max(0.0); let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0); + let sigma = radius * SMOOTH_SPREAD; let st = s.doc()?; let near = |q: Point| dist_to_polyline(&pts, q) <= radius; let mut plan = vec![]; @@ -148,7 +159,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { let subs: Vec = pd .subpaths .iter() - .map(|sp| match smooth_subpath(sp, &near, tol) { + .map(|sp| match smooth_subpath(sp, &near, tol, sigma) { Some(n) if n != *sp && n.anchors.len() >= 2 => { changed = true; n @@ -167,9 +178,13 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [], "before": 0, "after": 0 })); } let ids: Vec = plan.iter().map(|(id, _)| *id).collect(); - s.edit("Smooth", |d, _| { + s.edit("Smooth", |d, sel| { for (id, np) in plan { *path_mut(d, id)? = np; + // The smoothed path has new anchors, so the old anchor and segment indices are + // dropped and the path stays selected whole. + sel.anchors.remove(&id); + sel.segments.remove(&id); } Ok(()) })?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f3befb3462..12b1d9603c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -678,12 +678,22 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result { /// under an interaction that would then preview on, or cancel to, the document from before (#1719). /// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the /// document. A preview that Real-time Drawing off left unapplied is applied first, as the tool's own -/// commit applies it; when it fails, the interaction is canceled. +/// commit applies it; when it fails, the interaction is canceled. The applied preview's warning +/// goes to the running command's result or the tool's status messages, as the tool's own commit +/// reports it ([`Session::note_settled_warning`]). pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> { let typing = typing_in_progress(s); match s.unapplied_interaction_preview().filter(|_| !s.previewing) { - Some((cmd, p)) if s.preview(&cmd, &p).is_err() => s.cancel_interaction()?, - _ => s.commit_interaction()?, + Some((cmd, p)) => match s.preview(&cmd, &p) { + Ok(v) => { + if let Some(w) = v.get("warning").and_then(Value::as_str) { + s.note_settled_warning(w); + } + s.commit_interaction()? + } + Err(_) => s.cancel_interaction()?, + }, + None => s.commit_interaction()?, } if typing { s.set_tool_option("endTyping", &Value::Bool(true)); diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index 4ad89c773b..0adb06132a 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -135,6 +135,9 @@ fn touch(a: &Rect, b: &Rect) -> bool { struct Sets { parent: Vec, seeded: Vec, + /// How many open boxes the sweep's boxes met. + #[cfg(test)] + meets: usize, } impl Sets { @@ -180,17 +183,25 @@ impl Sets { /// tops that knows the lowest bottom below each of its branches, so each box meets only the open /// boxes it overlaps, not every open one (many objects stacked in a column, sharing their /// horizontal extent, would otherwise compare every pair). An open box inside a later box of -/// its group, ending no further right, is closed early: whatever overlaps it overlaps that box. -/// Fails only when cancelled ([`vectorcraft_doc::cancel`]). +/// its group, ending no further right, is closed early, and a box inside an open box of its group, +/// ending no further right, isn't opened: whatever overlaps it overlaps that box. Fails only when +/// cancelled ([`vectorcraft_doc::cancel`]). pub(super) fn overlapping(bounds: &[Option]) -> Result>, Cancelled> { Ok(sweep(bounds, None)?.groups()) } /// [`overlapping`]'s sweep. With `see_through`, two boxes are joined only when one of them is -/// see-through, and an open box is closed early only inside a later see-through box. +/// see-through, and an open box is closed early, or a box isn't opened, only inside a see-through +/// box. The open +/// see-through boxes and the open boxes without transparency are then in trees of their own, and +/// a box without transparency meets only the see-through ones. fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result { - let mut sets = Sets { parent: (0..bounds.len()).collect(), seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect() }; - let joins = |a: usize, b: usize| see_through.is_none_or(|s| s[a] || s[b]); + let mut sets = Sets { + parent: (0..bounds.len()).collect(), + seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect(), + #[cfg(test)] + meets: 0, + }; let boxes: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.filter(finite).map(|b| (i, b))).collect(); let n = boxes.len(); // Each box's place in the tree: by top (then index). @@ -201,9 +212,11 @@ fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result], see_through: Option<&[bool]>) -> Result &[0], + Some(_) if shadows => &[0, 1], + Some(_) => &[1], + }; let below = tops.partition_point(|t| *t <= b.y1); met.clear(); - stack.clear(); - stack.push((1, 0, size)); - while let Some((node, lo, hi)) = stack.pop() { - work += 1; - if work.is_multiple_of(4096) { - vectorcraft_doc::cancel::check()?; + for &t in looks { + stack.clear(); + stack.push((1, 0, size)); + while let Some((node, lo, hi)) = stack.pop() { + work += 1; + if work.is_multiple_of(4096) { + vectorcraft_doc::cancel::check()?; + } + if lo >= below || low[t][node] < b.y0 { + continue; + } + if hi - lo == 1 { + met.push(by_top[lo]); + continue; + } + let mid = (lo + hi) / 2; + stack.push((2 * node + 1, mid, hi)); + stack.push((2 * node, lo, mid)); } - if lo >= below || low[node] < b.y0 { - continue; - } - if hi - lo == 1 { - met.push(by_top[lo]); - continue; - } - let mid = (lo + hi) / 2; - stack.push((2 * node + 1, mid, hi)); - stack.push((2 * node, lo, mid)); } + #[cfg(test)] + { + sets.meets += met.len(); + } + // An open box it met that holds it, ending no further right, and that joins whatever it + // overlaps: whatever later overlaps this box overlaps that one and joins it, so this box + // isn't opened. + let holder = met.iter().copied().find(|&o| { + let ob = boxes[o].1; + see_through.is_none_or(|s| s[boxes[o].0]) && b.x1 <= ob.x1 && ob.y0 <= b.y0 && b.y1 <= ob.y1 + }); // Whatever overlaps a box inside this one overlaps this one, and joins it when this one is // see-through. - let shadows = see_through.is_none_or(|s| s[boxes[k].0]); for &o in &met { - if joins(boxes[k].0, boxes[o].0) { - sets.join(boxes[k].0, boxes[o].0); - } + sets.join(boxes[k].0, boxes[o].0); let ob = boxes[o].1; - if shadows && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { - set(&mut low, leaf[o], f64::NEG_INFINITY); + if shadows && Some(o) != holder && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { + set(&mut low[tree(o)], leaf[o], f64::NEG_INFINITY); } } - set(&mut low, leaf[k], b.y1); + if holder.is_none() { + set(&mut low[tree(k)], leaf[k], b.y1); + } } Ok(sets) } @@ -392,6 +425,11 @@ mod tests { /// What [`overlapping`] gives, comparing every pair. fn every_pair(bounds: &[Option]) -> Vec> { + every_pair_joining(bounds, |_, _| true) + } + + /// The groups of `bounds` comparing every pair, joining overlapping pairs that `joins`. + fn every_pair_joining(bounds: &[Option], joins: impl Fn(usize, usize) -> bool) -> Vec> { let n = bounds.len(); let mut group: Vec = (0..n).collect(); let hit = |a: &Rect, b: &Rect| a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1; @@ -401,6 +439,7 @@ mod tests { for j in 0..n { if let (Some(a), Some(b)) = (bounds[i], bounds[j]) && hit(&a, &b) + && joins(i, j) && group[j] < group[i] { group[i] = group[j]; @@ -464,6 +503,69 @@ mod tests { assert_eq!(overlapping(&odd).unwrap(), vec![vec![0, 2], vec![1]]); } + /// `n` boxes sharing their left and top edges, each inside the one before. + fn shrinking_nest(n: usize) -> Vec> { + (0..n).map(|i| Some(Rect::new(0.0, 0.0, 2.0 * (n - i) as f64, 2.0 * (n - i) as f64))).collect() + } + + /// Each box of a nest that shrinks to the right and bottom meets only the outermost box, which + /// stays open and holds it, not every box around it. + #[test] + fn a_shrinking_nest_meets_only_its_outermost_box() { + let n = 512; + let bounds = shrinking_nest(n); + let mut sets = sweep(&bounds, None).unwrap(); + assert_eq!(sets.meets, n - 1); + assert_eq!(sets.groups(), every_pair(&bounds)); + assert_eq!(sets.groups(), vec![(0..n).collect::>()]); + // Equal boxes: the later one isn't opened and the earlier one stays open for the third. + let same = [Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(9.0, 9.0, 20.0, 20.0))]; + assert_eq!(overlapping(&same).unwrap(), vec![vec![0, 1, 2]]); + // With transparency, an opaque box isn't opened inside a see-through box, and boxes inside + // an opaque box are opened: an opaque box that overlaps only the inner one still joins it + // when that one is see-through. + let see = [true, false]; + let mut sets = sweep(&bounds[..2], Some(&see)).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1]]); + let held = [Some(Rect::new(0.0, 0.0, 20.0, 20.0)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(5.0, 5.0, 8.0, 8.0))]; + let mut sets = sweep(&held, Some(&[false, true, false])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2]]); + let mut sets = sweep(&held, Some(&[true, false, false])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2]]); + let mut sets = sweep(&held, Some(&[false, false, true])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2]]); + let mut sets = sweep(&held, Some(&[false, false, false])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0], vec![1], vec![2]]); + } + + /// Boxes nested at shared corners and placed at random, some see-through, group with and + /// without transparency as comparing every pair groups them. + #[test] + fn nested_and_scattered_boxes_group_as_comparing_every_pair() { + let mut seed = 0x51_7cc1_b727_220au64; + let mut next = |m: f64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 1000) as f64 / 1000.0 * m + }; + for round in 0..40 { + let n = 6 + round * 3; + let bounds: Vec> = (0..n) + .map(|_| { + // Corners on a coarse grid so that edges are often shared and boxes often nest. + let (x, y) = ((next(6.0).floor()) * 10.0, (next(6.0).floor()) * 10.0); + let (w, h) = ((next(5.0).floor() + 1.0) * 10.0, (next(5.0).floor() + 1.0) * 10.0); + Some(Rect::new(x, y, x + w, y + h)) + }) + .collect(); + assert_eq!(overlapping(&bounds).unwrap(), every_pair(&bounds), "round {round}"); + let see: Vec = (0..n).map(|_| next(1.0) < 0.3).collect(); + let mut sets = sweep(&bounds, Some(&see)).unwrap(); + assert_eq!(sets.groups(), every_pair_joining(&bounds, |i, j| see[i] || see[j]), "round {round}, with transparency"); + } + } + /// A filled rectangle, unstroked. fn filled(id: u64, r: Rect) -> Node { let look = vectorcraft_doc::Appearance::basic( @@ -583,6 +685,62 @@ mod tests { assert_eq!(flatten_groups(&reach, &painted, &vec![false; n], 0.0).unwrap().len(), n); } + /// A small see-through box, then `n` opaque boxes nested around it, each inside the one + /// before and sharing its left and top edges. + fn nest_around_a_see_through_box(n: usize) -> (Vec>, Vec) { + let mut bounds = vec![Some(Rect::new(0.0, 0.0, 1.0, 1.0))]; + bounds.extend((0..n).map(|i| Some(Rect::new(0.0, 0.0, 2.0 * (n - i) as f64 + 1.0, 2.0 * (n - i) as f64 + 1.0)))); + let mut see = vec![false; n + 1]; + see[0] = true; + (bounds, see) + } + + /// Opaque boxes nested in each other around a see-through box each meet only the see-through + /// box, not the open opaque boxes they can't join, and all join it. + #[test] + fn nested_opaque_boxes_meet_only_the_see_through_box() { + let n = 512; + let (bounds, see) = nest_around_a_see_through_box(n); + let mut sets = sweep(&bounds, Some(&see)).unwrap(); + assert_eq!(sets.meets, n); + assert_eq!(sets.groups(), vec![(0..=n).collect::>()]); + // The unfiltered sweep that runs before it meets each box once too. + let mut sets = sweep(&bounds, None).unwrap(); + assert_eq!(sets.meets, n); + assert_eq!(sets.groups(), vec![(0..=n).collect::>()]); + let (painted, see) = nest_around_a_see_through_box(40); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + for fringe in [0.0, 0.7] { + let groups = flatten_groups(&reach, &painted, &see, fringe).unwrap(); + check(&reach, &painted, &see, fringe, &groups, &format!("nest, fringe {fringe}")); + assert_eq!(groups, vec![(0..=40).collect::>()]); + } + } + + /// An opaque box inside a later see-through box is closed: later boxes meet the see-through + /// box instead, and join the opaque one through it. + #[test] + fn a_see_through_box_closes_an_opaque_box_inside_it() { + // An opaque box, a see-through box around it, a see-through box over both and an opaque + // box over the first. + let bounds = [ + Some(Rect::new(0.0, 0.0, 10.0, 10.0)), + Some(Rect::new(0.0, 0.0, 20.0, 20.0)), + Some(Rect::new(5.0, 5.0, 30.0, 30.0)), + Some(Rect::new(8.0, 8.0, 12.0, 12.0)), + ]; + let see = [false, true, true, false]; + let mut sets = sweep(&bounds, Some(&see)).unwrap(); + // The second box meets the first; the third meets the second; the fourth meets the second + // and the third. + assert_eq!(sets.meets, 4); + assert_eq!(sets.groups(), vec![vec![0, 1, 2, 3]]); + // Opaque boxes alone meet nothing. + let mut sets = sweep(&bounds, Some(&[false; 4])).unwrap(); + assert_eq!(sets.meets, 0); + assert_eq!(sets.groups().len(), 4); + } + /// An opaque box between a member and its group's top that paints over the member joins, and /// so on along a chain; one under the member stays, but for one within the fringe. #[test] diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index aef1918a6d..fcc2a593c1 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1034,6 +1034,85 @@ fn an_opaque_pick_replaces_the_opacity_the_series_stores() { assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); } +/// A column graph whose series 0 stores the 40% Multiply fill set on its first mark, and a mark of series 1 whose top fill +/// row has no paint and is opaque and Normal: the session, the graph, series 0's marks and that source. +fn marks_and_unpainted_source() -> (Session, NodeId, Vec, NodeId) { + let (mut s, id, _, marks) = column_marks(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "none": true})).unwrap(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.4), Some(BlendMode::Multiply))); + (s, id, marks, source) +} + +/// An Eyedropper pick from a fill row without paint onto a mark whose fill had paint replaces the fill opacity and blend +/// mode another mark of the series stored: the series is drawn opaque and Normal again. +#[test] +fn an_eyedropper_pick_that_unpaints_a_series_fill_clears_its_stored_transparency() { + let (mut s, id, marks, source) = marks_and_unpainted_source(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill().unwrap().clone(); + assert_eq!((mark.paint.is_none(), mark.opacity, mark.blend), (false, 1.0, BlendMode::Normal), "the target's fill row"); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); + let picked = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill().unwrap().clone(); + assert_eq!((picked.paint.is_none(), picked.opacity, picked.blend), (true, 1.0, BlendMode::Normal)); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (None, None)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let fills: Vec<_> = series(&n, 0).children().unwrap().iter().filter_map(|m| m.appearance.fill().map(|f| (f.opacity, f.blend))).collect(); + assert!(fills.iter().all(|f| *f == (1.0, BlendMode::Normal)), "{fills:?}"); +} + +/// An Eyedropper pick from a fill row without paint onto a mark whose fill had no paint before the pick either leaves the +/// fill opacity and blend mode another mark of the series stored. +#[test] +fn an_eyedropper_pick_of_an_already_unpainted_row_leaves_series_transparency() { + let (mut s, id, marks, source) = marks_and_unpainted_source(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "none": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.4), Some(BlendMode::Multiply)), "a color set to None writes no opacity"); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.4), Some(BlendMode::Multiply))); +} + +/// A whole Eyedropper pick from a source with no fill row, or no stroke row, removes that row from the mark. Where the +/// mark's row had paint, the pick replaces the opacity and blend mode another mark of the series stored for that kind +/// of row. Where the mark's row had no paint, the stored values stay. +#[test] +fn a_whole_eyedropper_pick_that_removes_a_painted_row_clears_its_stored_transparency() { + let stored = |s: &Session, id: NodeId, fill: bool| { + let kept = spec(s, id).series_paints[0].clone(); + if fill { (kept.fill_opacity, kept.fill_blend) } else { (kept.stroke_opacity, kept.stroke_blend) } + }; + for (fill, painted) in [(true, true), (false, true), (false, false)] { + let (mut s, id, _, marks) = column_marks(); + let row = top_row(&s, marks[0], fill); + if painted { + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": row, "color": "#ff0000"})).unwrap(); + } + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": row, "color": "#ff0000", "opacity": 40, "blend": "multiply"})).unwrap(); + assert_eq!(stored(&s, id, fill), (Some(0.4), Some(BlendMode::Multiply)), "fill {fill}, painted {painted}"); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.removeItem", &json!({"ids": [source.0], "index": top_row(&s, source, fill)})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); + assert!(!mark.appearance.items.iter().any(|it| it.is_fill() == fill), "the pick removed the row: fill {fill}"); + let want = if painted { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) }; + assert_eq!(stored(&s, id, fill), want, "fill {fill}, painted {painted}"); + if painted { + let after = spec(&s, id).series_paints; + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, after); + } + } +} + /// An appearance moved between two marks of one series leaves the series with the target's paint, not the source's /// empty one, and the series still shows when the graph is drawn again. #[test] @@ -1251,6 +1330,45 @@ fn a_style_that_removes_a_marks_stroke_replaces_the_kept_stroke_opacity() { } } +/// An Eyedropper pick from a stroke row without paint onto a marker whose stroke had paint replaces the stroke opacity and +/// blend mode the series' Filled Lines band left, in the pick's undo step: the band's outline is drawn opaque and Normal +/// again. A pick onto a marker whose stroke had no paint before the pick leaves them as they are. +#[test] +fn an_eyedropper_pick_that_unpaints_a_series_stroke_clears_its_stored_transparency() { + for stroked in [true, false] { + let (mut s, id, band) = band_graph(); + let parts = series_parts(&s, id, 0); + let (marker, source) = (parts[1].id, parts[2].id); + let st = parts[2].appearance.stroke().unwrap(); + assert_eq!((st.paint.is_none(), st.opacity, st.blend), (true, 1.0, BlendMode::Normal), "the source's stroke row"); + if stroked { + let row = top_row(&s, marker, false); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": row, "color": "#ff0000"})).unwrap(); + } + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 40, "blend": "multiply"})).unwrap(); + let was = spec(&s, id).series_paints; + assert_eq!((was[0].stroke_opacity, was[0].stroke_blend), (Some(0.4), Some(BlendMode::Multiply))); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marker.0]})).unwrap(); + let picked = s.doc().unwrap().doc.node(marker).unwrap().appearance.stroke().unwrap().clone(); + assert_eq!((picked.paint.is_none(), picked.opacity, picked.blend), (true, 1.0, BlendMode::Normal), "stroked {stroked}"); + let kept = spec(&s, id).series_paints[0].clone(); + let want = if stroked { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) }; + assert_eq!((kept.stroke_opacity, kept.stroke_blend), want, "stroked {stroked}"); + if stroked { + let after = spec(&s, id).series_paints; + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + assert!(!s.doc().unwrap().doc.node(marker).unwrap().appearance.stroke_paint().is_none(), "the undo took back the pick"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, after); + } + redrawn_band(&mut s, id); + let st = series_parts(&s, id, 0)[0].appearance.stroke().unwrap().clone(); + assert_eq!((Some(st.opacity).filter(|o| *o < 1.0), Some(st.blend).filter(|b| *b != BlendMode::Normal)), want, "stroked {stroked}"); + } +} + /// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the /// band its color: an empty stored stroke is not a color for the band. #[test] diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index a6d2cbf518..ae8a7ef6b2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -346,6 +346,36 @@ impl Library { } } + /// Name item `r` `name`, in its place in its kind's list; an item keyed by its name keeps its + /// group under the new name (a graphic is keyed by its id, which stays). + fn rename_item(&mut self, r: &ItemRef, name: &str) { + fn set<'a>(mut names: impl Iterator, from: &str, to: &str) { + if let Some(n) = names.find(|n| n.as_str() == from) { + *n = to.to_string(); + } + } + let from = r.item.as_str(); + match r.kind { + ItemKind::Color => set(self.colors.iter_mut().map(|c| &mut c.name), from, name), + ItemKind::Gradient => set(self.gradients.iter_mut().map(|g| &mut g.name), from, name), + ItemKind::Pattern => set(self.patterns.iter_mut().map(|p| &mut p.name), from, name), + ItemKind::CharStyle => set(self.char_styles.iter_mut().map(|t| &mut t.name), from, name), + ItemKind::ParaStyle => set(self.para_styles.iter_mut().map(|t| &mut t.name), from, name), + ItemKind::Graphic => { + if let Some(g) = self.graphics.iter_mut().find(|g| g.id == from) { + g.name = name.to_string(); + } + return; + } + ItemKind::Unknown => return, + } + for x in self.groups.iter_mut().flat_map(|g| g.items.iter_mut()) { + if x == r { + x.item = name.to_string(); + } + } + } + /// Move item `r` next to `to`, an item of its kind: just before it, or just after it when /// `after`, in their kind's list (the order the panel shows them in). fn place_next_to(&mut self, r: &ItemRef, to: &ItemRef, after: bool) { @@ -683,7 +713,7 @@ pub fn specs() -> Vec { "Use Library Item", [], None, - "{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colors, gradients, patterns; default: the kind's), recorded?: the item as it was when used (recorded in the journal so a replay matches)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colors and gradients: paint the selection (and the defaults) as paint.setFill/paint.setStroke, a gradient fitting each object; a spot color links to the document's spot swatch of its ink's name, added when the document has none of that colour (a name taken by another colour gets a number), one undo step; patterns: add the pattern and its swatch to the document (a name the document gives another pattern gets a number; the same pattern is reused) and paint the selection (and the defaults) with it, one undo step; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}", + "{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colors, gradients, patterns; default: the kind's), recorded?: the item as it was when used (recorded in the journal so a replay matches)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colors and gradients: paint the selection (and the defaults) as paint.setFill/paint.setStroke, a gradient fitting each object; a spot color links to the document's spot swatch of its ink's name, added when the document has none of that colour (a name taken by another colour gets a number), one undo step; patterns: add the pattern and its swatch to the document (a name the document gives another pattern gets a number; the same pattern is reused) and paint the selection (and the defaults) with it, one undo step; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number; a character style's color linked to a swatch that isn't a global or spot swatch of the document keeps the color it shows, unlinked) and apply it to the selected text, one undo step → {kind, ids?, style?}", has_doc, use_item ), @@ -696,6 +726,24 @@ pub fn specs() -> Vec { always, remove_item ), + cmd!( + "library.renameItem", + "Rename Library Item", + [], + None, + "{library?, kind, item: name (graphic: id or name), name: string (trimmed; 1 to 256 characters)} rename an item; it keeps its place and its group; another item of its kind with that name is an error, and its own name changes nothing; not undoable → {library, kind, item: its key now (graphic: its id), name}", + always, + rename_item + ), + cmd!( + "library.addToCharacterStyles", + "Add to Character Styles", + [], + None, + "{library?, item: a character style's name, recorded?: the style as it was when added (recorded in the journal so a replay matches)} add a library character style to the document's character styles without applying it (no text needs to be selected): a document style of that name with the same attributes is reused, a name taken by different attributes gets a number; a color linked to a swatch that isn't a global or spot swatch of the document keeps the color it shows, unlinked; a new style is one undo step, a reused one none → {style: its name in the document, existing: bool}", + has_doc, + add_to_char_styles + ), cmd!( "library.createGroup", "New Library Group", @@ -1231,8 +1279,13 @@ fn use_item(s: &mut Session, p: &Value) -> Result { Kind::CharStyle | Kind::ParaStyle => { let para = kind == Kind::ParaStyle; let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string(); - let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default(); + let mut attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default(); let doc = &s.doc()?.doc; + if !para { + unlink_missing_swatches(doc, &mut attrs); + } + // Compared in the form the document stores them in. + let attrs = super::textstyles::stored_style_attrs(doc, para, &attrs).unwrap_or(attrs); // The document's style of that name when it is the same; else a new one. let same = super::textstyles::style_attrs(doc, para, &name).is_some_and(|a| a == attrs); let style = if same { name } else { unique(&name, |n| super::textstyles::style_attrs(doc, para, n).is_some()) }; @@ -1361,6 +1414,109 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { ok() } +fn rename_item(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.renameItem"; + let (id, kind, item) = item_param(s, p, C)?; + let name = match p.get("name") { + None | Some(Value::Null) => return Err(bad(C, "missing `name`")), + Some(Value::String(n)) => n.trim().to_string(), + Some(_) => return Err(bad(C, "`name` must be a string")), + }; + if name.is_empty() { + return Err(bad(C, "missing `name`")); + } + if name.chars().count() > MAX_NAME { + let (label, max) = ("`name`", MAX_NAME); + return Err(bad(C, format!("{label} must be at most {max} characters"))); + } + let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?; + let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?; + // The item's name now, and whether another item of its kind has the new one. + let mut same = false; + let mut taken = false; + for (_, key, shown) in lib.items().filter(|(k, ..)| *k == r.kind) { + if key == r.item { + same = shown == name; + } else if shown == name { + taken = true; + } + } + let key = if r.kind == ItemKind::Graphic { r.item.clone() } else { name.clone() }; + let out = json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "name": name }); + if same { + return Ok(out); + } + if taken { + return Err(bad(C, format!("the library has another item of that kind named `{name}`"))); + } + s.libraries.change(&id, |lib| { + lib.rename_item(&r, &name); + Ok(()) + })?; + Ok(out) +} + +fn add_to_char_styles(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.addToCharacterStyles"; + // A replay (the journal) carries what the style was, so it doesn't depend on the library. + let data = match p.get("recorded").cloned() { + Some(r) => r, + None => { + let id = library_id(s, p, C)?; + let item = str_param(p, "item").ok_or_else(|| bad(C, "missing `item`"))?; + let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?; + let t = lib.char_styles.iter().find(|t| t.name == item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?; + let data = json!({ "name": t.name, "attrs": t.attrs }); + s.note_journal("recorded", data.clone()); + data + } + }; + let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string(); + let mut attrs = data.get("attrs").and_then(Value::as_object).cloned().ok_or_else(|| bad(C, "the library item is damaged"))?; + let doc = &s.doc()?.doc; + unlink_missing_swatches(doc, &mut attrs); + // Compared in the form the document stores them in. + let attrs = super::textstyles::stored_style_attrs(doc, false, &attrs).unwrap_or(attrs); + // The document's style of that name when it is the same; else a new one, which isn't applied. + if super::textstyles::style_attrs(doc, false, &name).is_some_and(|a| a == attrs) { + return Ok(json!({ "style": name, "existing": true })); + } + let style = unique(&name, |n| super::textstyles::style_attrs(doc, false, n).is_some()); + s.execute("charStyle.new", &json!({ "name": style, "attrs": attrs }))?; + Ok(json!({ "style": style, "existing": false })) +} + +/// Unlink a library character style's `fill` and `stroke` colors (solid, and each gradient stop) +/// from swatches that are not global or spot swatches of `doc`: an unlinked color keeps the color +/// it shows at full tint, as [`clean_gradient`] keeps a library gradient's stops. A link to a +/// global or spot swatch `doc` has stays, and takes that swatch's color. +fn unlink_missing_swatches(doc: &vectorcraft_doc::Document, attrs: &mut Map) { + let keep = |name: &str| doc.global_color(name).is_some(); + for key in ["fill", "stroke"] { + let Some(mut paint) = attrs.get(key).and_then(|v| serde_json::from_value::(v.clone()).ok()) else { continue }; + let mut changed = false; + let mut unlink = |swatch: &mut Option, tint: &mut f32| { + if swatch.as_deref().is_some_and(|n| !keep(n)) { + *swatch = None; + *tint = 1.0; + changed = true; + } + }; + match &mut paint { + Paint::Solid { swatch, tint, .. } => unlink(swatch, tint), + Paint::Gradient(g) => { + for st in &mut g.gradient.stops { + unlink(&mut st.swatch, &mut st.tint); + } + } + _ => {} + } + if changed && let Ok(v) = serde_json::to_value(&paint) { + attrs.insert(key.to_string(), v); + } + } +} + /// `p`'s `name`, trimmed and clipped, else `default`. fn name_param(p: &Value, default: &str) -> String { let mut name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(default).to_string(); diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 8a92d759f9..692ce9e563 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -172,7 +172,7 @@ pub fn specs() -> Vec { "Make with Top Object", ["Object", "Envelope Distort"], Some("Cmd+Alt+C"), - "{ids?} the topmost selected path becomes the envelope of the others: its outline splits into four sides at its corners, and each piece of a side takes a share of the content in proportion to its control polygon's length (type maps from its glyph outline bounds) → {id}", + "{ids?} the topmost selected path becomes the envelope of the others: its outline splits into four sides at its corners, and each piece of a side takes a share of the content in proportion to its control polygon's length, and a side whose pieces are all one curve split at anchors follows that curve's parameter (type maps from its glyph outline bounds) → {id}", has_multi, env_make_top ), diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index c617c4de8e..ac57d1852f 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -1228,7 +1228,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { Some(l) => po::simplify_level_tolerance(l), None => given_tolerance.unwrap_or(1.0), }; - let opts = po::SimplifyOptions { tolerance: tolerance.clamp(1e-4, 1e4), corner_angle_deg: corner_angle, straight_lines, smooth: 0.0 }; + let opts = po::SimplifyOptions { tolerance: tolerance.clamp(1e-4, 1e4), corner_angle_deg: corner_angle, straight_lines, ..Default::default() }; // A path with segments direct-selected simplifies only their runs, and keeps those runs (and the // other anchors it had selected) direct-selected. let roots = selected_roots(s)?; diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index dfa44a1436..3fa082bdb0 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -672,13 +672,53 @@ fn takes_over(s: &Session, plugin: &Plugin) -> bool { } } +#[cfg(test)] +thread_local! { + /// Tests: runs once in the next `plugin.install` on this thread after its checks and before it + /// installs, as another session changing the plug-ins in between would. + pub(crate) static BEFORE_INSTALL: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Tests: runs once in the next `plugin.install` on this thread right after it reads the grant + /// held for the id. + pub(crate) static AFTER_GRANT_READ: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Tests: runs once in the next `plugin.install` on this thread after it installs and before it + /// writes the store. + pub(crate) static BEFORE_STORE: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Tests: runs once on this thread when [`hold_plugin_changes`] finds the lock held and waits. + pub(crate) static WHILE_WAITING: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// Held by `plugin.install` from its first read of the grant until the store holds what it +/// installed, and throughout by `plugin.remove`, by loads of the stored plug-ins +/// ([`Session::load_stored_plugins`]) and by loads of a plug-ins folder: these run one after the +/// other across sessions. +static PLUGIN_CHANGES: Mutex<()> = Mutex::new(()); + +/// Takes [`PLUGIN_CHANGES`], waiting while another session holds it. +pub(crate) fn hold_plugin_changes() -> std::sync::MutexGuard<'static, ()> { + match PLUGIN_CHANGES.try_lock() { + Ok(held) => held, + Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => { + #[cfg(test)] + if let Some(waiting) = WHILE_WAITING.take() { + waiting(); + } + PLUGIN_CHANGES.lock().unwrap_or_else(|e| e.into_inner()) + } + } +} + /// `plugin.install`: loads and checks the module, refuses `grant` and `allowHeavy` that neither an /// [`InstallApproval`] for this module nor the session allows, one that asks for a heavy budget /// unless `allowHeavy` says a person agreed, one that lists permissions no person has granted this /// very module unless `grant` lists them all, one that would take another module's place -/// ([`takes_over`]) unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records -/// the grant with the module's hash, keeps it for the next start and installs it → its -/// `plugin.list` entry. A module that lists no permissions ends any grant held for its id. +/// ([`takes_over`]) unless `replace` is true, and one whose SHA-256 isn't the `hash` given. It then +/// checks the plug-in installed under the id and its grant again while it records the grant with +/// the module's hash and installs it ([`registry::install_checked`]), and only then keeps both for +/// the next start → its `plugin.list` entry. A refused install changes neither the plug-in store +/// nor the plug-ins and grants of the process. A module that lists no permissions ends any grant +/// held for its id. A `plugin.remove` in another session waits until the install has written the +/// store. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; // One use: spent whatever this install does. @@ -696,21 +736,36 @@ fn install(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed")); } - if replace == Some(false) && registry::get(plugin.id()).is_some() { - return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); + // A removal in another session waits until the store holds what this install wrote. + let _held = hold_plugin_changes(); + // The grant the checks below count on, the approval included. The install checks it again, + // with the plug-in installed under the id, before anything is stored: another session may + // change either in between. + let seen_grant = registry::granted(plugin.id()); + #[cfg(test)] + if let Some(between) = AFTER_GRANT_READ.take() { + between(); } - // Another module under the id gets the settings and cache kept for it: whoever installs it asks - // the user first. - if replace != Some(true) && takes_over(s, &plugin) { + let installed_already = || bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())); + let taken_over = || { let name = permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME); - return Err(bad( + bad( C, format!( "“{name}” is another module than the plug-in with id {:?} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees", plugin.id() ), - )); + ) + }; + if replace == Some(false) && registry::get(plugin.id()).is_some() { + return Err(installed_already()); } + // Another module under the id gets the settings and cache kept for it: whoever installs it asks + // the user first. + if replace != Some(true) && takes_over(s, &plugin) { + return Err(taken_over()); + } + let (installed_already, taken_over) = (installed_already(), taken_over()); // `grant` and `allowHeavy` say that a person agreed, and a request alone can't say that. let approval = approval.filter(|a| a.covers(&plugin)); if !s.plugin_grants_from_requests && ((grant.is_some() && approval.is_none()) || (allow_heavy && !approval.is_some_and(|a| a.heavy))) { @@ -744,15 +799,55 @@ fn install(s: &mut Session, p: &Value) -> Result { // this very module already covers all of them. let m = plugin.manifest(); let wanted = m.permissions(); - if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { + let needs_approval = || { let name = permissions::display_text(&m.name, permissions::MAX_SHOWN_NAME); let asked = permissions::names(&wanted).join(", "); - return Err(bad( + bad( C, format!( "“{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants" ), - )); + ) + }; + let granted_here = grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))); + // Without a plug-in store, the grant read above approves the module or doesn't. + let approved = match s.plugin_dir { + None => !m.asks_permissions() || seen_grant.as_ref().is_some_and(|g| g.covers(&m.id, plugin.hash(), &wanted)), + Some(_) => s.plugin_approved(&plugin), + }; + if !approved && !granted_here { + return Err(needs_approval()); + } + let needs_approval = needs_approval(); + let (id, hash, asks) = (m.id.clone(), plugin.hash().to_string(), m.asks_permissions()); + let g = Grant { id: id.clone(), hash: hash.clone(), permissions: wanted }; + let update = if asks { registry::GrantUpdate::Record(GrantToken::after_engine_approval(g.clone())) } else { registry::GrantUpdate::Revoke }; + // Another module installed under the id since the checks above takes the place they found free, + // or held by these bytes. A grant that changed since no longer approves what they approved, + // unless `grant` approves this module by itself or the new grant covers it. + let check = |installed: Option<&Plugin>, held: Option<&Grant>| { + if let Some(installed) = installed { + match replace { + Some(false) => return Err(installed_already), + None if installed.hash() != hash => return Err(taken_over), + _ => {} + } + } + if asks && !granted_here && held != seen_grant.as_ref() && !held.is_some_and(|h| h.covers(&id, &hash, &g.permissions)) { + return Err(needs_approval); + } + Ok(()) + }; + #[cfg(test)] + if let Some(between) = BEFORE_INSTALL.take() { + between(); + } + // The plug-in gets the settings and cache kept for its id. A module without permissions takes + // the id over, so nothing the user approved for the id applies any more. + let installed = registry::install_checked(plugin, update, check, || s.attach_plugin_storage(&id), err)?; + #[cfg(test)] + if let Some(between) = BEFORE_STORE.take() { + between(); } // Installed plug-ins stay installed after a restart (#2541). The module is written first and its // grant only once it is there; when the grant can't be written, the store gets the module it @@ -760,18 +855,14 @@ fn install(s: &mut Session, p: &Value) -> Result { // which the grant doesn't cover. A write that reports a failure may still have put the old // module back, so the store is read again to tell which module it holds. A stored module that // can't be read back isn't replaced by one that needs a grant. - let previous = s.stored_plugin_bytes(plugin.id()); - let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); - let holds_new = |s: &Session| s.stored_plugin_bytes(plugin.id()).is_ok_and(|held| held.as_deref() == Some(&bytes[..])); - if m.asks_permissions() { - let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; - registry::grant(GrantToken::after_engine_approval(g.clone())); - if stored && !s.store_grant(&m.id, Some(g)) { - stored = !s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()) && holds_new(s); + let previous = s.stored_plugin_bytes(&id); + let mut stored = (previous.is_ok() || !asks) && s.store_plugin(&id, &bytes); + let holds_new = |s: &Session| s.stored_plugin_bytes(&id).is_ok_and(|held| held.as_deref() == Some(&bytes[..])); + if asks { + if stored && !s.store_grant(&id, Some(g)) { + stored = !s.put_back_plugin(&id, previous.ok().flatten().as_deref()) && holds_new(s); } } else { - // This module takes the id over, so nothing the user approved for the id applies any more. - registry::revoke(&m.id); // When the write failed, the old stored module stays in the folder and needs its grant at // the next launch. A grant that can't be removed still approves the old bytes, so the store // gets the module it held back. When that module can't be read or written back, or there @@ -780,23 +871,21 @@ fn install(s: &mut Session, p: &Value) -> Result { // approves nothing, and the new module stays; one that can't be opened or read may approve // the old bytes once it reads again. let writes = s.grant_writes(); - if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) { - let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref())); + if stored && !s.store_grant(&id, None) && s.holds_plugin_grant(&id) { + let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(&id, held.as_deref())); stored = !back && holds_new(s); if !back || matches!(previous, Ok(None)) { - s.ignore_stored_grant(&m.id, writes); + s.ignore_stored_grant(&id, writes); } } } - // It gets the settings and cache kept for its id. - s.attach_plugin_storage(plugin.id()); // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, // also when it is disabled by its stored file alone (it stopped a launch while loading). When // the new version could not replace the stored file, the old one stays disabled. - let file = s.stored_plugin_file(plugin.id()); + let file = s.stored_plugin_file(&id); let mut changed = false; if file.is_none() || stored { - changed = safe_mode::set_enabled(&safe_mode::Item::of(&plugin), true); + changed = safe_mode::set_enabled(&safe_mode::Item::of(&installed), true); if let Some(file) = file { changed |= safe_mode::set_enabled(&safe_mode::Item::file(file), true); } @@ -804,7 +893,6 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - let installed = registry::install(plugin).map_err(err)?; let mut v = describe_installed(&installed); if !unknown.is_empty() { v["unknownCommands"] = json!(unknown); @@ -904,6 +992,8 @@ fn load_folder(s: &Session, folder: &str) -> Value { None if crate::file_access::current().is_none() => Ok(()), None => Err(format!("automation path rejected: {} isn't valid text", f.display())), }; + // A `plugin.install` in another session finishes writing the store first. + let _held = hold_plugin_changes(); match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_folder_approver()) { Ok(r) => { for id in &r.loaded { @@ -1074,6 +1164,8 @@ fn info(_: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + // An install in another session finishes its store writes first. + let _held = hold_plugin_changes(); let installed = registry::get(id).is_some(); // Settings and cache the store keeps for an id that isn't installed are deleted too. let stored = !installed && vectorcraft_plugins::manifest::valid_id(id) && s.has_plugin_storage(id); diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 5958a84b53..8071c1df3d 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -478,6 +478,7 @@ pub(super) const ALLOWED: &[&str] = &[ "recolor.pickTheme", "recolor.randomize", "recolor.reduce", + "recolor.weigh", "select.add", "select.all", "select.allOnArtboard", diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 931bcce681..8194f85547 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -7,7 +7,9 @@ use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; -use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink}; +use vectorcraft_color::recolor::{ + ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, WeighUnit, cluster, row_shares, same_ink, weigh as weigh_rows, +}; use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; @@ -63,6 +65,15 @@ pub fn specs() -> Vec { has_doc, pick_theme ), + cmd!( + query "recolor.weigh", + "Prominent Colors", + [], + None, + "{map: rows [{from, to, …}] or {current: new} as recolor.apply, weights?: [numbers ≥ 0, one per row; an excluded row's is ignored, and at least one other must be above 0], ids?: as recolor.colors} → {map: the rows as given, each with its current colours (from) moved as below, shares: [one per row: its percent of the uses (recolor.colors' count) of the current colours of the rows that aren't excluded; 0 for an excluded row]} for Recolor Artwork's Prominent Colors (VectorCraft's own rule): without weights the rows stay as they are. With weights, current colours move one at a time between the rows that aren't excluded and have a new colour (to), while a move brings each row's share of the uses nearer its share of the weights, first the colour nearest in Lab to the first colour of the row it goes to compared with the row it leaves; a row's first colour stays in it, the tints of one global swatch in a row move together, a colour that another row with a new colour lists first stays where it is, the other rows with a new colour drop their entries for a colour that moves, and a use counts for the row recolor.apply recolors it with (rows with a new colour first, an exact colour before a hex one, the first row that lists it), else for the first other row that isn't excluded and lists it. The colours that can move are compared with the rows at most 50,000,000 times in all, and when they times the rows that aren't excluded pass that, none move. Changes nothing", + has_doc, + weigh + ), ] } @@ -1062,6 +1073,181 @@ fn colors(s: &mut Session, p: &Value) -> Result { Ok(json!({ "colors": v })) } +/// `recolor.weigh`'s rows as given, each with `from` as a list (a `{current: new}` map gives a +/// row per entry). The rows have already been read by [`parse_rows`]. +fn given_rows(p: &Value) -> Vec { + match p.get("map") { + Some(Value::Array(rows)) => rows + .iter() + .map(|r| { + let mut r = r.clone(); + if let Some(f) = r.get("from").filter(|f| !f.is_array()).cloned() { + r["from"] = json!([f]); + } + r + }) + .collect(), + Some(Value::Object(m)) => m.iter().map(|(k, v)| json!({"from": [k], "to": v})).collect(), + _ => vec![], + } +} + +fn weigh(s: &mut Session, p: &Value) -> Result { + const C: &str = "recolor.weigh"; + let rows = parse_rows(p, C)?; + let weights = match p.get("weights") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) if a.len() == rows.len() => Some( + a.iter() + .map(|v| { + v.as_f64() + .filter(|x| x.is_finite() && *x >= 0.0) + .ok_or_else(|| bad(C, format!("each weight must be a number of 0 or more (got {v})"))) + }) + .collect::>>()?, + ), + Some(v) => return Err(bad(C, format!("`weights` must be a list of {} numbers, one per row (got {v})", rows.len()))), + }; + let active: Vec = (0..rows.len()).filter(|&i| !rows[i].exclude).collect(); + if let Some(w) = &weights + && !active.is_empty() + && !active.iter().any(|&i| w[i] > 0.0) + { + return Err(bad(C, "at least one row that isn't excluded needs a weight above 0")); + } + let (ids, only) = scope_ids(s, p, C)?; + let st = s.doc()?; + let mut used: Vec> = used_colors(&st.doc, &ids, only.as_ref()).into_iter().map(Some).collect(); + let mut by_key: HashMap = HashMap::new(); + let mut by_hex: HashMap> = HashMap::new(); + for (i, u) in used.iter().enumerate().filter_map(|(i, u)| Some((i, u.as_ref()?))) { + by_key.insert(ColorKey::of(&u.color), i); + by_hex.entry(u.color.to_hex()).or_default().push(i); + } + // Each current colour's uses and global swatch. A use counts where recolor.apply recolors it: + // rows with a new colour first, exact colours before hex colours, the first row that lists it; + // then the other rows that aren't excluded, in the same way. `owner` holds the row and entry that took each colour, + // and `shadows` the entries of rows with a new colour whose colours other entries took. + let mut owner: Vec> = vec![None; used.len()]; + let mut shadows: Vec<(usize, usize, Vec)> = vec![]; + let mut found: Vec)>> = rows.iter().map(|r| vec![(0, None); r.from.len()]).collect(); + for recolors in [true, false] { + for hex in [false, true] { + for (r, row) in rows.iter().enumerate().filter(|(_, r)| !r.exclude && r.new_color().is_some() == recolors) { + for (e, m) in row.from.iter().enumerate().filter(|(_, m)| matches!(m, Match::Hex(_)) == hex) { + let hits: Vec = match m { + Match::Key(k) => by_key.get(k).copied().into_iter().collect(), + Match::Hex(h) => by_hex.get(h).cloned().unwrap_or_default(), + }; + let (mut uses, mut link, mut took, mut taken) = (0, None, false, vec![]); + for i in hits { + match used[i].take() { + Some(u) => { + uses += u.count; + link = link.or(u.swatch); + took = true; + owner[i] = Some((r, e)); + } + None => taken.push(i), + } + } + found[r][e] = (uses, link); + if recolors && !took && !taken.is_empty() { + shadows.push((r, e, taken)); + } + } + } + } + } + // The units that move: in each row that isn't excluded, the colours linked to one global + // swatch go together, each other colour on its own, and the unit of the first colour stays. + let (mut units, mut members): (Vec, Vec<(usize, Vec)>) = (vec![], vec![]); + let mut unit_of: HashMap<(usize, usize), usize> = HashMap::new(); + for (n, &r) in active.iter().enumerate() { + let mut groups: Vec<(Option<&String>, Vec, usize)> = vec![]; + let mut by_link: HashMap<&String, usize> = HashMap::new(); + for (e, (uses, link)) in found[r].iter().enumerate() { + let g = match link { + Some(l) => *by_link.entry(l).or_insert_with(|| { + groups.push((Some(l), vec![], 0)); + groups.len() - 1 + }), + None => { + groups.push((None, vec![], 0)); + groups.len() - 1 + } + }; + groups[g].1.push(e); + groups[g].2 += uses; + } + for (k, (_, entries, uses)) in groups.into_iter().enumerate() { + for &e in &entries { + unit_of.insert((r, e), units.len()); + } + units.push(WeighUnit { color: rows[r].from[entries[0]].color(), uses: uses as f64, row: n, fixed: k == 0 }); + members.push((r, entries)); + } + } + // recolor.apply matches a colour to the first row with a new colour that lists it. A colour + // that another such row's first colour also lists stays where it is; another row's other + // entries for a colour that moves are dropped from that row below. + let owners = |taken: &[usize]| -> Vec { taken.iter().filter_map(|&i| unit_of.get(&owner[i]?).copied()).collect() }; + for (_, _, taken) in shadows.iter().filter(|(_, e, _)| *e == 0) { + for u in owners(taken) { + units[u].fixed = true; + } + } + if let Some(w) = &weights { + // Only rows with a new colour recolor what they hold, so only they give and take colours. + let movers: Vec = (0..active.len()).filter(|&n| rows[active[n]].new_color().is_some()).collect(); + let anchors: Vec = + movers.iter().map(|&n| rows[active[n]].from.first().map(Match::color).or(rows[active[n]].to).unwrap_or_default()).collect(); + let mut mover_of = vec![None; active.len()]; + for (m, &n) in movers.iter().enumerate() { + mover_of[n] = Some(m); + } + let at: Vec> = units.iter().map(|u| mover_of[u.row]).collect(); + let sub: Vec = units.iter().zip(&at).filter_map(|(u, m)| Some(WeighUnit { row: (*m)?, ..*u })).collect(); + let to = weigh_rows(&sub, &anchors, &movers.iter().map(|&n| w[active[n]]).collect::>()); + for ((u, _), m) in units.iter_mut().zip(&at).filter(|(_, m)| m.is_some()).zip(to) { + u.row = movers[m]; + } + } + let mut out = given_rows(p); + if weights.is_some() { + // Each row's colours: those that stayed, in their order, then those that came to it. + // A row's entry for a colour that moved to another row would take it back, so it goes. + let moved = |u: usize| active[units[u].row] != members[u].0; + let dropped: std::collections::HashSet<(usize, usize)> = + shadows.iter().filter(|(_, e, taken)| *e != 0 && owners(taken).into_iter().any(moved)).map(|(r, e, _)| (*r, *e)).collect(); + let old: Vec> = out.iter().map(|r| r["from"].as_array().cloned().unwrap_or_default()).collect(); + let mut stayed: Vec> = active.iter().map(|_| vec![]).collect(); + let mut came: Vec> = active.iter().map(|_| vec![]).collect(); + for (u, (r, entries)) in units.iter().zip(&members) { + let kept = entries.iter().copied().filter(|&e| !dropped.contains(&(*r, e))); + if active[u.row] == *r { + stayed[u.row].extend(kept); + } else { + came[u.row].extend(kept.filter_map(|e| old[*r].get(e).cloned())); + } + } + for (n, &r) in active.iter().enumerate() { + stayed[n].sort_unstable(); + let mut from: Vec = stayed[n].iter().filter_map(|&e| old[r].get(e).cloned()).collect(); + from.append(&mut came[n]); + if let Some(row) = out.get_mut(r) { + row["from"] = Value::Array(from); + } + } + } + let shares = row_shares(&units, active.len()); + let mut all = vec![0.0; rows.len()]; + for (n, &r) in active.iter().enumerate() { + all[r] = shares[n]; + } + Ok(json!({"map": out, "shares": all})) +} + /// The `method` parameter (`default` without one, or for null). A `method` that is not a string, /// such as `5`, names no method. fn method_param(p: &Value, cmd: &str, default: Method) -> Result { diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 99cfc0a3fc..8134976144 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -506,6 +506,13 @@ pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option) -> Option> { + let kind = if para { Kind::Para } else { Kind::Char }; + attrs_param(d, &json!({ "attrs": attrs }), kind, "new", None).ok().flatten() +} + /// The name `charStyle.new` (`para`: `paraStyle.new`) gives a style when it is given none: the /// first "Character Style N" ("Paragraph Style N") that no character style (paragraph style) of the /// document has. diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 6c80c962d6..c1c3801e40 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -135,6 +135,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { let mut story: Vec = vec![]; let mut sources: Vec = vec![]; let mut later_wins = vec![]; + let mut earlier_wins = vec![]; let mut live = vec![]; for id in &thread { match (text_of(doc, *id), text_of(before, *id)) { @@ -143,6 +144,9 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { // A frame whose first paragraph was just restyled gives the paragraph it // shares with the frame before it that style. later_wins.push(old.is_some_and(|o| o.para_at(0) != t.para_at(0))); + // A frame whose last paragraph was just restyled keeps that style in the + // paragraph it shares with the frame after it, even when it is empty. + earlier_wins.push(old.is_some_and(|o| o.para_at(o.paragraph_count() - 1) != t.para_at(t.paragraph_count() - 1))); sources.push(t.clone()); live.push(*id); } @@ -151,6 +155,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { (None, Some(old)) => { story.extend(old.runs.iter().cloned()); later_wins.push(false); + earlier_wins.push(false); sources.push(old.clone()); } (None, None) => {} @@ -160,7 +165,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { continue; } let source_refs: Vec<&TextObject> = sources.iter().collect(); - let paras = vectorcraft_text::thread::story_paras(&source_refs, &later_wins); + let paras = vectorcraft_text::thread::story_paras_restyled(&source_refs, &later_wins, &earlier_wins); vectorcraft_text::edit::normalize(&mut story); if live.len() >= 2 { // Threaded frames keep their size: Auto Size is off for them (as in Illustrator). diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index b6ea41df3e..a622901c01 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -60,6 +60,130 @@ fn a_restyled_empty_frame_keeps_its_style_before_an_unedited_empty_frame() { assert_eq!([hyphenates(a, 0), hyphenates(a, 1), hyphenates(b, 0), hyphenates(c, 0)], [false, true, true, true]); } +/// Every paragraph's left indent in frame `id`. +fn left_indents(s: &Session, id: u64) -> Vec { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.paragraph_styles().iter().map(|p| p.left_indent).collect(), + _ => panic!("not text"), + } +} + +/// Area type `height` high holding `text`, threaded into `empties` empty rectangles after it. +fn tail_thread(text: &str, height: f64, empties: usize) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 300})).unwrap(); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": height}})).unwrap()); + let mut ids = vec![a]; + for k in 0..empties { + let x = 200.0 * (k + 1) as f64; + ids.push(new_id(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 150, "height": 60})).unwrap())); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + (s, ids) +} + +/// Restyling a frame's empty last paragraph (after its final paragraph break) gives that +/// paragraph the style in the next frame as well, instead of the next frame's old style. +#[test] +fn editing_a_frames_empty_last_paragraph_survives_thread_reflow() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let a = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "a\n", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!([text(&s, a), text(&s, b)], ["a\n", ""]); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0])); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + // The same format again changes nothing and records no undo step. + s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 0.0], vec![0.0])); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0])); + s.execute("edit.undo", &json!({})).unwrap(); + + // A format of the whole frame changes its last paragraph as well. + s.execute("text.setFormat", &json!({"ids": [a], "leftIndent": 20})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![20.0, 20.0], vec![20.0])); + // Both frames formatted in one command agree. + s.execute("text.setFormat", &json!({"ids": [a, b], "leftIndent": 40})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![40.0, 40.0], vec![40.0])); +} + +/// The restyled empty last paragraph keeps its style when the next frame holds text that +/// continues it, and across an unedited empty frame; a later restyle of a later frame still wins. +#[test] +fn a_restyled_empty_last_paragraph_keeps_its_style_against_later_frames() { + // One line high: the text added to the next frame stays there. + let (mut s, ids) = tail_thread("a\n", 20.0, 1); + let (a, b) = (ids[0], ids[1]); + s.execute("text.editRange", &json!({"id": b, "start": 0, "end": 0, "insert": "def"})).unwrap(); + assert_eq!([text(&s, a), text(&s, b)], ["a\n", "def"]); + s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!([text(&s, a), text(&s, b)], ["a\n", "def"]); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0])); + + let (mut s, ids) = tail_thread("a\n", 60.0, 2); + s.execute("text.setFormat", &json!({"ids": [ids[0]], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!(ids.iter().map(|id| left_indents(&s, *id)).collect::>(), [vec![0.0, 30.0], vec![30.0], vec![30.0]]); + s.execute("text.setFormat", &json!({"ids": [ids[2]], "leftIndent": 10})).unwrap(); + assert_eq!(ids.iter().map(|id| left_indents(&s, *id)).collect::>(), [vec![0.0, 10.0], vec![10.0], vec![10.0]]); +} + +/// The restyled last paragraph keeps its style with type on a path on either side of the boundary. +#[test] +fn a_restyled_empty_last_paragraph_flows_onto_type_on_a_path() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 300})).unwrap(); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "a\n", "area": {"width": 150, "height": 60}})).unwrap()); + let on_path = type_on_a_line(&mut s); + s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); + assert_eq!([text(&s, a), text(&s, on_path)], ["a\n", ""]); + s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, on_path)), (vec![0.0, 30.0], vec![30.0])); + + // Type on a path before area type. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 300})).unwrap(); + let on_path = type_on_a_line(&mut s); + s.execute("text.editRange", &json!({"id": on_path, "start": 0, "end": 0, "insert": "a\n"})).unwrap(); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()); + s.execute("text.thread.add", &json!({"from": on_path, "to": b})).unwrap(); + assert_eq!([text(&s, on_path), text(&s, b)], ["a\n", ""]); + s.execute("text.setFormat", &json!({"ids": [on_path], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!((left_indents(&s, on_path), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0])); +} + +/// A restyled empty last paragraph in a numbered list is counted once, and the next frame's +/// first paragraph does not continue the paragraph before the break. +#[test] +fn a_restyled_empty_last_list_paragraph_keeps_the_counts() { + let (mut s, ids) = tail_thread("One\nTwo\n", 60.0, 1); + let (a, b) = (ids[0], ids[1]); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a], "start": 8, "end": 8, "leftIndent": 30})).unwrap(); + let doc = &s.doc().unwrap().doc; + let frame = |id| match &doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + }; + let (ta, tb) = (frame(a), frame(b)); + let shown = [ta.plain_text(), tb.plain_text()].concat(); + assert_eq!(shown, "One\nTwo\n"); + let split = ta.paragraph_count() - 1; + assert_eq!(ta.para_at(split).left_indent, 30.0, "{:?}", [ta.plain_text(), tb.plain_text()]); + assert_eq!(tb.para_at(0).left_indent, 30.0); + assert!(tb.para_at(0).list.is_some()); + assert_eq!(tb.list_count as usize, split, "the paragraphs before the shared one are counted once"); + assert_eq!(tb.first_para_continues, !ta.plain_text().ends_with('\n')); +} + #[test] fn thread_flows_reflows_on_edit_and_survives_delete() { let (mut s, a, b) = setup(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3c3460ecb2..e3f2b91ee7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -286,6 +286,9 @@ pub struct Session { /// While the active tool's actions run: their commands aren't "a command from outside the /// tool" ([`Session::after_command`]). pub(crate) in_tool_actions: bool, + /// The warnings of the previews that settling an interaction applied, while a top-level command + /// or the tool's actions collect them ([`Session::collect_settled_warnings`]). + settled_warnings: Option>, /// While [`Session::preview`] runs its command: its edits are the interaction's own. previewing: bool, /// The images Remove Background decoded during the interaction's preview steps, emptied when diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index 4ec68dbbde..f1928228df 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -321,7 +321,8 @@ impl Files { std::env::var("JSINCLUDE").map(|v| v.split(';').map(str::trim).filter(|p| !p.is_empty()).map(PathBuf::from).collect()).unwrap_or_default() } - /// The file being run now, as `$.fileName` gives it (empty for a script given as text). + /// The file being run now: the script, or the file `$.evalFile` is running (empty for a script + /// given as text). `$.fileName` gives it to code that `eval` or `Function` compiled. pub(crate) fn file_name(&self) -> String { self.frames.last().map(|f| f.shown.to_string()).unwrap_or_default() } diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index e82ab1e275..217125eecb 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -850,6 +850,29 @@ impl Host { placed.unwrap_or_else(|| ("[unknown]".to_string(), 0)) } + /// `$.fileName` read by the code at `site`: the file that code is in, by the source map of the + /// source it came from (this run's script, a file `$.evalFile` gave, or code an earlier run of + /// the named engine compiled), so a function keeps the file it was defined in. Code from a file + /// `#include` inserted is in that file. A script given as text has no name, and neither has + /// code whose map the named engine no longer keeps. Code that `eval` or `Function` compiled has + /// no map, and is given the file being run now. + fn file_name_at(&self, site: vectorcraft_js::CallSite) -> String { + let name = |m: &include::SourceMap| m.locate(site.line, site.column).1.to_string(); + let tag = site.source; + if tag == vectorcraft_js::UNKNOWN_SOURCE { + return self.files.borrow().file_name(); + } + let script = self.script_tag.get(); + match tag.checked_sub(script).filter(|_| script != vectorcraft_js::UNKNOWN_SOURCE) { + Some(0) => name(&self.script_map.borrow()), + Some(n) => self.eval_maps.borrow().get(n as usize - 1).map(name).unwrap_or_default(), + None => { + let kept = self.kept_maps.as_ref().map(|k| k.borrow()); + kept.as_ref().and_then(|k| k.get(tag)).map(|k| name(&k.map)).unwrap_or_default() + } + } + } + /// Adds a line to the run's console and, during a slice, to the session's. fn line(&self, entry: console::Entry, cut: bool) { let line = console::ConsoleLine { run: self.run, name: self.name.clone(), entry }; @@ -1282,9 +1305,9 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) { /// The `$` object: `$.write(...values)` writes the values to the Script Console as `String(value)` /// gives them, one after another, and `$.writeln(...values)` does the same and ends the line. -/// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file -/// being run and `$.includePath` the `#includepath` list. Its other members are -/// [`dollar::members`]. +/// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file the +/// code reading it is in ([`Host::file_name_at`]) and `$.includePath` the `#includepath` list. Its +/// other members are [`dollar::members`]. fn dollar_object(link: &Link, info: dollar::RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { let writer = |end: bool| { let l = link.clone(); @@ -1306,6 +1329,15 @@ fn dollar_object(link: &Link, info: dollar::RunInfo, localization: &Rc(&mut self, f: impl FnOnce(&mut Self) -> R) -> (R, Vec) { + let outer = self.settled_warnings.replace(Vec::new()); + let r = f(self); + let settled = std::mem::replace(&mut self.settled_warnings, outer).unwrap_or_default(); + (r, settled) + } + pub fn begin_interaction(&mut self, label: &str) -> Result<()> { if self.script_running() { return Err(EngineError::Busy); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 23079fe901..b03487604b 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -3,6 +3,27 @@ use super::{ strip_replay_only, waits_for_tool_edit, }; +/// A command's result `r` with the warnings of the previews that settling an interaction applied +/// before it ran: `previewWarning` holds them, and `warning` too when the command gives none of its +/// own, so the status bar shows the command's own warning when there is one. A null result becomes +/// an object holding them; any other result that isn't an object is returned as it is. +fn with_settled_warnings(mut r: Value, settled: &[String]) -> Value { + if settled.is_empty() { + return r; + } + if r.is_null() { + r = Value::Object(serde_json::Map::new()); + } + if let Value::Object(m) = &mut r { + let w = Value::String(settled.join("; ")); + if m.get("warning").is_none_or(Value::is_null) { + m.insert("warning".into(), w.clone()); + } + m.insert("previewWarning".into(), w); + } + r +} + impl Session { /// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back. fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec) -> std::result::Result<(), String> { @@ -54,7 +75,14 @@ impl Session { self.finish_tool_edit_later()?; } let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id)); - let r = self.execute_spec(spec, id, params); + // A preview that a top-level command applied first (a drag with Real-time Drawing off, kept + // before Undo) reports its warning in the command's result. + let r = if self.depth == 0 { + let (r, settled) = self.collect_settled_warnings(|s| s.execute_spec(spec, id, params)); + r.map(|v| with_settled_warnings(v, &settled)) + } else { + self.execute_spec(spec, id, params) + }; self.reading = outer; r } @@ -364,3 +392,25 @@ impl Session { r } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::with_settled_warnings; + + /// The command's own warning stays the one the status bar shows, and the settled preview's is + /// kept beside it; a null result holds the preview's; other results are left as they are. + #[test] + fn settled_warnings_join_the_result() { + let settled = ["a".to_string(), "b".to_string()]; + assert_eq!( + with_settled_warnings(json!({"ran": 1, "warning": "own"}), &settled), + json!({"ran": 1, "warning": "own", "previewWarning": "a; b"}) + ); + assert_eq!(with_settled_warnings(json!({"warning": null}), &settled), json!({"warning": "a; b", "previewWarning": "a; b"})); + assert_eq!(with_settled_warnings(json!(null), &settled[..1]), json!({"warning": "a", "previewWarning": "a"})); + assert_eq!(with_settled_warnings(json!([1]), &settled), json!([1])); + assert_eq!(with_settled_warnings(json!({"ran": 1}), &[]), json!({"ran": 1})); + } +} diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 4943353ee0..8a18ce9824 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -421,6 +421,8 @@ impl Session { if safe_mode::active() { return; } + // A `plugin.install` in another session writes the store before this reads it. + let _held = crate::cmd::plugin::hold_plugin_changes(); let grants = self.counted_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); let mut left = MAX_PLUGIN_STORE; diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 5986b618fd..9f7b56295c 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1367,6 +1367,28 @@ fn smooth_tool_reduces_jagged_path() { assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0)); } +/// Smoothing a path with direct-selected anchors leaves the path selected whole: the selection no +/// longer names anchors by indices into the anchors the smooth replaced. A selected path the drag +/// does not reach keeps its anchors selected, and undo brings the old selection back. +#[test] +fn smoothing_a_path_drops_its_direct_selected_anchors() { + let mut s = session(); + let anchors: Vec<_> = (0..=40).map(|i| json!({"x": 100.0 + i as f64 * 5.0, "y": 200.0 + if i % 2 == 0 { 0.0 } else { 1.5 }})).collect(); + let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let other = line(&mut s, 100.0, 500.0, 300.0, 500.0); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 38]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + drag(&mut s, "smooth", &[(90.0, 200.0), (200.0, 200.0), (310.0, 200.0)], Mods::default()); + assert!(path(&s, id).anchor_count() < 20, "the path is smoothed"); + let sel = s.doc().unwrap().selection.clone(); + assert!(sel.contains(id) && sel.contains(other), "{sel:?}"); + assert!(!sel.anchors.contains_key(&id) && !sel.segments.contains_key(&id), "{sel:?}"); + assert_eq!(sel.anchors.get(&other), Some(&[(0, 1)].into()), "the path out of reach keeps its anchor"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.anchors.get(&id), Some(&[(0, 38)].into())); +} + /// #1512: a short drag over a small bump smooths the stretch of path under it. The long straight /// sides next to the bump stay straight lines from their far ends, which keep their place. #[test] @@ -1390,6 +1412,169 @@ fn a_short_smooth_drag_leaves_the_rest_of_long_segments_alone() { } } +/// A selected path through `anchors` (x, y, and the in and out handles when given). +fn smooth_test_path(s: &mut Session, anchors: &[serde_json::Value], closed: bool) -> NodeId { + let r = s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + id +} + +/// `path.smoothRegion` along `drag` with the Smooth tool's reach (12 pt at 100%) and default Fidelity. +fn smooth_pass(s: &mut Session, drag: &[Point]) { + let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect(); + s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": 2.5})).unwrap(); +} + +/// Points 1/32 of a segment apart along every segment of `p`. +fn smooth_test_samples(p: &PathData) -> Vec { + use kurbo::ParamCurve; + let mut v = vec![]; + for sp in &p.subpaths { + for i in 0..sp.segment_count() { + let c = sp.segment(i); + v.extend((0..32).map(|k| c.eval(f64::from(k) / 32.0))); + } + } + v +} + +/// How much `p` turns along its length, in radians: a wobbly path turns much more than a smooth one. +fn total_turning(p: &PathData) -> f64 { + let pts = smooth_test_samples(p); + pts.windows(3) + .map(|w| (w[1] - w[0], w[2] - w[1])) + .filter(|(u, v)| u.hypot() > 1e-9 && v.hypot() > 1e-9) + .map(|(u, v)| u.cross(v).atan2(u.dot(v)).abs()) + .sum() +} + +/// Deterministic noise in -1..1. +fn smooth_noise(seed: &mut u64) -> f64 { + *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (*seed >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 +} + +/// A drag along y = `y` from x = `x0` to `x1`, one point every 5 pt. +fn smooth_drag_along(y: f64, x0: f64, x1: f64) -> Vec { + let n = ((x1 - x0) / 5.0).ceil() as usize; + (0..=n).map(|i| Point::new(x0 + (x1 - x0) * i as f64 / n as f64, y)).collect() +} + +/// Each drag of the Smooth tool smooths the path under it, and a second drag over the same place +/// smooths it further, as the help page describes +/// (https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html): a curve that wobbles +/// 4 pt either side of its line every 12 pt, with an anchor at each crest, wobbles less after each drag. +#[test] +fn each_smooth_drag_smooths_a_wobble_larger_than_fidelity_further() { + let mut s = session(); + let anchors: Vec<_> = (0..=20) + .map(|k| { + let (x, y) = (100.0 + 12.0 * f64::from(k), if k % 2 == 0 { 196.0 } else { 204.0 }); + json!({"x": x, "y": y, "in": [x - 4.4, y], "out": [x + 4.4, y]}) + }) + .collect(); + let id = smooth_test_path(&mut s, &anchors, false); + let drag = smooth_drag_along(200.0, 90.0, 350.0); + let wobble = + |p: &PathData| smooth_test_samples(p).iter().filter(|q| (130.0..=310.0).contains(&q.x)).map(|q| (q.y - 200.0).abs()).fold(0.0, f64::max); + let mut before = path(&s, id); + for pass in 1..=2 { + smooth_pass(&mut s, &drag); + let after = path(&s, id); + assert_ne!(after, before, "pass {pass} changes the path"); + let (t0, t1) = (total_turning(&before), total_turning(&after)); + assert!(t1 < 0.9 * t0, "pass {pass}: turning {t0} -> {t1}"); + let (w0, w1) = (wobble(&before), wobble(&after)); + assert!(w1 < 0.8 * w0, "pass {pass}: wobble {w0} -> {w1}"); + let (a, b) = (before.subpaths[0].anchors[0].p, after.subpaths[0].anchors[0].p); + assert_eq!(a, b, "pass {pass}: the path keeps its start"); + before = after; + } +} + +/// Smoothing a line with up to 1 pt of noise again and again, over all of it or over its middle, +/// keeps it close to the line under the noise: each refit stays near the path it smooths. +#[test] +fn repeated_smooth_drags_stay_close_to_the_line_under_the_noise() { + let off = |p: &PathData| { + let d: Vec = smooth_test_samples(p).iter().map(|q| (q.y - 200.0).abs()).collect(); + (d.iter().sum::() / d.len() as f64, d.iter().copied().fold(0.0, f64::max)) + }; + for (x0, x1) in [(90.0, 410.0), (150.0, 350.0)] { + let mut s = session(); + let mut seed = 7u64; + let anchors: Vec<_> = (0..=60).map(|i| json!({"x": 100.0 + 5.0 * f64::from(i), "y": 200.0 + smooth_noise(&mut seed)})).collect(); + let id = smooth_test_path(&mut s, &anchors, false); + let drag = smooth_drag_along(200.0, x0, x1); + let (mean0, _) = off(&path(&s, id)); + for pass in 1..=5 { + smooth_pass(&mut s, &drag); + let (mean, max) = off(&path(&s, id)); + assert!( + mean <= 1.1 * mean0 && max <= 1.5, + "drag {x0}-{x1}, pass {pass}: {mean} on average and {max} at most from the line, {mean0} before" + ); + } + } +} + +/// A drag over the whole of a noisy closed path leaves no corner where the path starts. +#[test] +fn smoothing_a_whole_closed_path_leaves_no_kink_at_its_start() { + let mut s = session(); + let mut seed = 5u64; + let (c, r) = (Point::new(300.0, 300.0), 80.0); + let anchors: Vec<_> = (0..64) + .map(|i| { + let a = f64::from(i) / 64.0 * std::f64::consts::TAU; + let rr = r + 2.5 * smooth_noise(&mut seed); + json!({"x": c.x + rr * a.cos(), "y": c.y + rr * a.sin()}) + }) + .collect(); + let id = smooth_test_path(&mut s, &anchors, true); + let ring: Vec = (0..=128).map(|i| f64::from(i) / 128.0 * std::f64::consts::TAU).map(|a| c + r * kurbo::Vec2::from_angle(a)).collect(); + smooth_pass(&mut s, &ring); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert!(sp.closed && sp.anchors.len() < 64, "{} anchors", sp.anchors.len()); + let (last, first) = (sp.segment(sp.segment_count() - 1), sp.segment(0)); + let tin = if last.p3.distance(last.p2) > 1e-9 { last.p3 - last.p2 } else { last.p3 - last.p1 }; + let tout = if first.p1.distance(first.p0) > 1e-9 { first.p1 - first.p0 } else { first.p2 - first.p0 }; + let kink = tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees(); + assert!(kink <= 1.0, "a {kink}° corner at the start"); +} + +/// A drag over the whole of a closed path smooths a bump at its first anchor as much as the same +/// bump on the far side of it. +#[test] +fn smoothing_a_whole_closed_path_smooths_a_bump_at_its_start() { + let mut s = session(); + let (c, r) = (Point::new(300.0, 300.0), 80.0); + let anchors: Vec<_> = (0..64) + .map(|i| { + let a = f64::from(i) / 64.0 * std::f64::consts::TAU; + let rr = if i % 32 == 0 { r + 6.0 } else { r }; + json!({"x": c.x + rr * a.cos(), "y": c.y + rr * a.sin()}) + }) + .collect(); + let id = smooth_test_path(&mut s, &anchors, true); + let ring: Vec = (0..=128).map(|i| f64::from(i) / 128.0 * std::f64::consts::TAU).map(|a| c + r * kurbo::Vec2::from_angle(a)).collect(); + // A Fidelity of 0.5 pt, so the refit follows the smoothed path closely on both sides. + let pts: Vec<_> = ring.iter().map(|p| json!([p.x, p.y])).collect(); + s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": 0.5})).unwrap(); + let p = path(&s, id); + use std::f64::consts::{PI, TAU}; + // How far the path rises above the circle within 0.3 radians of the angle `near`. + let bump = |near: f64| { + let off = |q: &Point| (((q.y - c.y).atan2(q.x - c.x) - near + PI).rem_euclid(TAU) - PI).abs(); + smooth_test_samples(&p).iter().filter(|q| off(q) < 0.3).map(|q| q.distance(c) - r).fold(0.0, f64::max) + }; + let (start, far) = (bump(0.0), bump(PI)); + assert!(far < 5.0, "the far bump stays {far} pt high"); + assert!(start <= far + 0.5, "the bump at the start stays {start} pt high, the far one {far} pt"); +} + #[test] fn join_tool_joins_two_lines() { let mut s = session(); diff --git a/crates/engine/src/tests/expand.rs b/crates/engine/src/tests/expand.rs index 3fd187016f..d853b91110 100644 --- a/crates/engine/src/tests/expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -535,9 +535,9 @@ fn expanded_art_keeps_the_random_transforms_the_canvas_drew() { } /// The reply lists each object whose appearance expanded once, by the id it has in the document, -/// starting with the selected object. In "group copies" the two copies of the square share an id -/// until Expand Appearance gives them ids of their own, and the reply names the group, then each -/// copy by its new id. +/// starting with the selected object. In "group copies" the group's art holds the square with its +/// stroke's effect applied, so the reply names the group alone; the two copies of the square share +/// ids until Expand Appearance gives every object in them an id of its own. #[test] fn the_reply_names_each_expanded_copy_by_its_own_id() { for case in RANDOM_CASES { @@ -551,8 +551,17 @@ fn the_reply_names_each_expanded_copy_by_its_own_id() { assert!(ids.iter().all(|&i| s.doc().unwrap().doc.node(NodeId(i)).is_some()), "{case}: {ids:?}"); if case == "group copies" { let art = node(&s, id); - let copies: Vec = art.children().unwrap()[0].children().unwrap().iter().map(|c| c.id.0).collect(); - assert_eq!(ids, [vec![id.0], copies].concat(), "{case}"); + assert_eq!(ids, [id.0], "{case}"); + let copies = art.children().unwrap(); + assert_eq!(copies.len(), 2, "{case}"); + let mut seen = std::collections::HashSet::new(); + let mut repeated = vec![]; + art.walk(&mut |n| { + if !seen.insert(n.id) { + repeated.push(n.id); + } + }); + assert!(repeated.is_empty(), "{case}: {repeated:?}"); } } } diff --git a/crates/engine/src/tests/library.rs b/crates/engine/src/tests/library.rs index 15a8da8104..d7927ec6bf 100644 --- a/crates/engine/src/tests/library.rs +++ b/crates/engine/src/tests/library.rs @@ -635,6 +635,210 @@ fn a_library_style_named_with_spaces_comes_in_trimmed() { assert_eq!((text.runs[0].style.size, text.runs[0].style.style_name.as_deref()), (31.0, Some("Body"))); } +/// The size of the first run of text object `id`. +fn run_size(s: &Session, id: &Value) -> f64 { + match &s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => t.runs[0].style.size, + _ => 0.0, + } +} + +/// Add to Character Styles puts a library character style into the document's character styles +/// without applying it, with or without type selected, in one undo step; the same style there is +/// reused without a step, and a name taken by other attributes gets a number. A bad call changes +/// nothing. +#[test] +fn a_library_character_style_is_added_to_the_document_without_applying_it() { + let mut s = session(); + let data = json!({"name": "Kept", "charStyles": [{"name": "Headline", "attrs": {"size": 31.0}}, {"name": "Broken", "attrs": {"size": "big"}}]}); + let lib = run(&mut s, "library.import", json!({"data": data.to_string()}))["id"].as_str().unwrap().to_string(); + let styles = |s: &Session| s.doc().unwrap().doc.char_styles.iter().map(|c| c.name.clone()).collect::>(); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + // Nothing selected. + let before = undo(&s); + let r = run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Headline"})); + assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Headline"), Some(false))); + assert_eq!(styles(&s), ["Headline"]); + assert_eq!(undo(&s), before + 1, "one undo step"); + let attrs = &s.doc().unwrap().doc.char_styles[0].attrs; + assert_eq!(attrs.get("size"), Some(&json!(31.0))); + // Selected type keeps its look. + let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [t]})); + let before = undo(&s); + let r = run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Headline"})); + assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Headline"), Some(true))); + assert_eq!((undo(&s), run_size(&s, &t), styles(&s).len()), (before, 9.0, 1), "reused: no step, the type unchanged"); + // Another style of that name: numbered, still not applied. + run(&mut s, "charStyle.setAttrs", json!({"name": "Headline", "attrs": {"size": 5}})); + let r = run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Headline"})); + assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Headline 2"), Some(false))); + assert_eq!(run_size(&s, &t), 9.0); + let vectorcraft_doc::NodeKind::Text(text) = &s.doc().unwrap().doc.node(NodeId(t.as_u64().unwrap())).unwrap().kind else { panic!("not text") }; + assert_eq!(text.runs[0].style.style_name, None, "no style applied"); + // Undo takes the added style back alone. + run(&mut s, "edit.undo", json!({})); + assert_eq!(styles(&s), ["Headline"]); + // Bad calls: nothing changes. + let before = (undo(&s), styles(&s)); + for bad in [ + json!({"library": lib}), + json!({"library": lib, "item": 3}), + json!({"library": lib, "item": "Nope"}), + json!({"library": "Nope", "item": "Headline"}), + json!({"library": lib, "item": "Broken"}), + json!({"recorded": {"name": "X", "attrs": "damaged"}}), + ] { + assert!(s.execute("library.addToCharacterStyles", &bad).is_err(), "{bad}"); + assert_eq!((undo(&s), styles(&s)), before, "{bad}"); + } + // Without a document it isn't available. + run(&mut s, "file.close", json!({})); + assert!(s.active().is_none()); + assert!(s.execute("library.addToCharacterStyles", &json!({"library": lib, "item": "Headline"})).is_err()); +} + +/// A library character style whose fill links to a swatch the document lacks is added and applied +/// with the color it shows, unlinked at full tint; a global swatch of that name in the document +/// keeps the link and gives its color. +#[test] +fn a_library_character_style_linked_to_a_missing_swatch_is_added_unlinked() { + let base = vectorcraft_color::Color::rgb(0.2, 0.4, 0.6); + let shown = base.tinted(0.5); + let linked = vectorcraft_color::Paint::Solid { color: shown, swatch: Some("Brand".into()), tint: 0.5 }; + let data = json!({"name": "Kept", "charStyles": [{"name": "Accent", "attrs": {"size": 20.0, "fill": linked}}]}); + let mut s = session(); + let lib = run(&mut s, "library.import", json!({"data": data.to_string()}))["id"].as_str().unwrap().to_string(); + let fill = |s: &Session, name: &str| { + let st = s.doc().unwrap().doc.char_styles.iter().find(|c| c.name == name).map(|c| c.attrs.clone()).unwrap(); + serde_json::from_value::(st["fill"].clone()).unwrap() + }; + run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Accent"})); + assert_eq!(fill(&s, "Accent"), vectorcraft_color::Paint::Solid { color: shown, swatch: None, tint: 1.0 }); + // Apply as well, on selected type. + let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [t]})); + let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": "Accent"})); + assert_eq!(r["style"], "Accent", "the added style is reused"); + assert_eq!(run_size(&s, &t), 20.0); + // A document with a global swatch of that name keeps the link. + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let other = vectorcraft_color::Color::rgb(0.8, 0.1, 0.1); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": other, "global": true})); + run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Accent"})); + let vectorcraft_color::Paint::Solid { swatch, tint, .. } = fill(&s, "Accent") else { panic!("not a solid fill") }; + assert_eq!((swatch.as_deref(), tint), (Some("Brand"), 0.5)); + // The style with the document's swatch color is the same style: added again, it is reused. + let r = run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Accent"})); + assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Accent"), Some(true))); +} + +/// A library style is compared with the document's in the form the document stores it in, so a +/// style whose file writes a size as a whole number is reused, by Add to Character Styles and by a +/// click alike. +#[test] +fn a_library_style_is_reused_whatever_form_its_file_writes_it_in() { + let data = json!({"name": "Kept", "charStyles": [{"name": "Whole", "attrs": {"size": 31}}], "paraStyles": [{"name": "Para", "attrs": {"left_indent": 4}}]}); + let mut s = session(); + let lib = run(&mut s, "library.import", json!({"data": data.to_string()}))["id"].as_str().unwrap().to_string(); + run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Whole"})); + let r = run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Whole"})); + assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Whole"), Some(true))); + let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [t]})); + for (kind, item) in [("charStyle", "Whole"), ("paraStyle", "Para"), ("paraStyle", "Para")] { + assert_eq!(run(&mut s, "library.use", json!({"library": lib, "kind": kind, "item": item}))["style"], item); + } + let d = &s.doc().unwrap().doc; + assert_eq!((d.char_styles.len(), d.para_styles.iter().filter(|p| p.name.starts_with("Para")).count()), (1, 1)); +} + +/// Rename gives a library item a new name, trimmed, in its place and its group; a character style +/// keeps its attributes, a graphic its id. Another item of its kind with that name, an empty or +/// too long name, a name that isn't text and an unknown item or library are errors that change +/// nothing; a name with path characters is a name like any other; the item's own name writes +/// nothing; and the document's history is not touched. +#[test] +fn library_items_are_renamed_in_place() { + let (mut s, g, c, st) = brand(); + let t = run(&mut s, "text.create", json!({"x": 10, "y": 120, "text": "Body", "size": 9}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [t]})); + let other = run(&mut s, "library.add", json!({"kind": "charStyle", "name": "Body"}))["name"].as_str().unwrap().to_string(); + run(&mut s, "library.createGroup", json!({"name": "Brand", "items": [{"kind": "charStyle", "item": st}, {"kind": "fillColor", "item": c}]})); + let undo = s.doc().unwrap().history.undo.len(); + let attrs = run(&mut s, "library.get", json!({}))["charStyles"][0]["attrs"].clone(); + let r = run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": st, "name": " Title "})); + assert_eq!((r["item"].as_str(), r["name"].as_str()), (Some("Title"), Some("Title"))); + let got = run(&mut s, "library.get", json!({})); + assert_eq!(got["charStyles"][0], json!({"name": "Title", "attrs": attrs}), "first still, the attributes kept"); + assert_eq!(got["charStyles"][1]["name"], other.as_str()); + assert_eq!(groups(&mut s), [("Brand".into(), refs(&[("charStyle", "Title"), ("color", &c)]))]); + // A colour keeps its group under its new name; a graphic keeps its id. + run(&mut s, "library.renameItem", json!({"kind": "strokeColor", "item": c, "name": "Navy"})); + assert_eq!(groups(&mut s), [("Brand".into(), refs(&[("charStyle", "Title"), ("color", "Navy")]))]); + let r = run(&mut s, "library.renameItem", json!({"kind": "graphic", "item": "Logo", "name": "Mark"})); + assert_eq!(r["item"], g.as_str()); + let graphic = run(&mut s, "library.get", json!({}))["graphics"][0].clone(); + assert_eq!((graphic["id"].as_str(), graphic["name"].as_str()), (Some(g.as_str()), Some("Mark"))); + // Path characters are kept. + run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Title", "name": "a/b\\c:d"})); + assert_eq!(style_order_of(&mut s)[0], "a/b\\c:d"); + // Errors change nothing. + let before = run(&mut s, "library.get", json!({})); + let long = "x".repeat(257); + for bad in [ + json!({"kind": "charStyle", "item": "a/b\\c:d", "name": other}), + json!({"kind": "charStyle", "item": "a/b\\c:d", "name": " "}), + json!({"kind": "charStyle", "item": "a/b\\c:d", "name": long}), + json!({"kind": "charStyle", "item": "a/b\\c:d", "name": 5}), + json!({"kind": "charStyle", "item": "a/b\\c:d"}), + json!({"kind": "charStyle", "item": "Nope", "name": "X"}), + json!({"kind": "paraStyle", "item": "a/b\\c:d", "name": "X"}), + json!({"kind": "brush", "item": "a/b\\c:d", "name": "X"}), + json!({"library": "Nope", "kind": "charStyle", "item": "a/b\\c:d", "name": "X"}), + ] { + assert!(s.execute("library.renameItem", &bad).is_err(), "{bad}"); + assert_eq!(run(&mut s, "library.get", json!({})), before, "{bad}"); + } + // A style of another kind may have the name, and 256 characters are allowed. + run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "a/b\\c:d", "name": "Navy"})); + run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Navy", "name": "y".repeat(256)})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no document undo step"); +} + +/// The current library's character style names, in order. +fn style_order_of(s: &mut Session) -> Vec { + order(s, "charStyles", "name") +} + +/// A rename is kept in the library's file; renaming an item to its own name writes nothing; and a +/// rename whose file can't be written is taken back. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_library_item_rename_is_saved_or_taken_back() { + let dir = vectorcraft_testkit::temp_dir("library-rename-item"); + let folder = dir.to_string_lossy().to_string(); + let mut s = session(); + s.libraries.set_dir(Some(folder.clone())); + let lib = run(&mut s, "library.create", json!({"name": "Kept"}))["id"].as_str().unwrap().to_string(); + let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 12}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [t]})); + run(&mut s, "library.add", json!({"kind": "charStyle", "name": "Body"})); + run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Body", "name": "Text"})); + let mut t2 = session(); + t2.libraries.set_dir(Some(folder.clone())); + assert_eq!(style_order_of(&mut t2), ["Text"], "kept in the file"); + // The file can't be written (a folder stands where it goes): its own name still succeeds, as + // it writes nothing; a new name fails and is taken back. + let file = dir.join(format!("{lib}.vclibrary")); + std::fs::remove_file(&file).unwrap(); + std::fs::create_dir(&file).unwrap(); + run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Text", "name": " Text "})); + assert!(s.execute("library.renameItem", &json!({"kind": "charStyle", "item": "Text", "name": "Copy"})).is_err()); + assert_eq!(style_order_of(&mut s), ["Text"], "taken back"); + let _ = std::fs::remove_dir_all(&dir); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn libraries_are_kept_in_files_across_sessions() { diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 10939d4b2a..c372bcf784 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -643,6 +643,32 @@ fn envelope_top_object_shares_a_side_by_control_polygon_length() { assert!(near(Point::new(500.0, 200.0)) < 0.5, "the middle of the bottom: {pts:?}"); } +#[test] +fn envelope_top_object_keeps_its_look_when_anchors_are_added() { + // Add Anchor Points splits each segment of the top object in two without changing its shape. + // The top curve's parameter runs fast at its start, so its halves have control polygons of + // different lengths; the envelope still bends the two rectangles as the unsplit curve does. + let made = |add_anchors: bool| { + let mut s = session(); + let left = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let right = rect(&mut s, 200.0, 100.0, 100.0, 100.0); + let d = "M400 100 C520 40 590 70 600 100 L600 200 L400 200 Z"; + let t = id_of(&s.execute("path.create", &json!({ "d": d })).unwrap()); + if add_anchors { + sel(&mut s, &[t]); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + assert_eq!(node(&s, t).path_data().map(|p| p.anchor_count()), Some(8)); + } + sel(&mut s, &[left, right, t]); + let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap()); + all_anchors(&live::expand_live(&node(&s, e))) + }; + let (plain, split) = (made(false), made(true)); + assert_eq!(plain.len(), split.len()); + let off = plain.iter().zip(&split).map(|(p, q)| p.distance(*q)).fold(0.0, f64::max); + assert!(off < 1e-6, "the art moves by up to {off} pt"); +} + #[test] fn envelope_release_and_expand() { let mut s = session(); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 38918cf2c4..1c931e0de8 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -257,6 +257,25 @@ fn shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canva } } +/// A group's Transform effect runs after its member's own Offset Path in Expand Appearance as on +/// the canvas: the 100 pt square offset to 120 pt and scaled by half expands 60 pt wide. +#[test] +fn a_groups_effects_after_its_members_own_expand_as_the_canvas_draws_them() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}})); + run(&mut s, "object.group", json!({})); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 50}})); + let before = render(&s); + run(&mut s, "effect.expandAppearance", json!({})); + let after = render(&s); + let diff = differing(&before, &after, 64); + assert_eq!(diff, 0, "{diff} pixels differ"); + let doc = &s.doc().unwrap().doc; + let w = doc.node(doc.layers[0].id).and_then(|l| l.geometric_bounds()).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + #[test] fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { // The object's own effect, and the fill's (the first appearance item). diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index cc4460d250..2661e85787 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -2820,6 +2820,188 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { vectorcraft_plugins::registry::revoke(id); } +/// An install refused because another session revoked the grant its checks relied on writes +/// nothing: a store that kept the grant and lost the module gets no module back, its grants stay as +/// they were, and the plug-in installed with the id and the other session's revoke stand. `grant` +/// approves the module by itself. +#[test] +fn a_grant_revoked_while_installing_leaves_the_store_as_it_was() { + use vectorcraft_plugins::permissions::sha256_hex; + use vectorcraft_plugins::registry; + let folder = crate::tests_links::Folder::new("plugin-store-revoked-while-installing"); + let id = "test.engine.permissions.revokedwhileinstalling"; + let bytes = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let data = vectorcraft_format::base64_encode(&bytes); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap()); + std::fs::remove_file(&module).unwrap(); + + crate::cmd::plugin::BEFORE_INSTALL.set(Some(Box::new(move || { + registry::revoke(id); + }))); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("asks for the permissions doc.read"), "{e}"); + assert!(!module.exists(), "no module is stored"); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grants stay as they were"); + assert!(registry::granted(id).is_none(), "the other session's revoke stands"); + assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&bytes)), "the installed plug-in stays"); + + crate::cmd::plugin::BEFORE_INSTALL.set(Some(Box::new(move || { + registry::revoke(id); + }))); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]})); + assert_eq!(std::fs::read(&module).unwrap(), bytes, "a module `grant` approves is stored"); + assert!(registry::granted(id).is_some_and(|g| g.hash == sha256_hex(&bytes))); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A plug-in another session installs with the id while a module is checked takes the place the +/// checks found free. Without `replace`, or with `replace: false`, the install is refused, writes no +/// module and leaves that plug-in installed; with `replace: true` the module takes its place. +#[test] +fn a_plug_in_installed_while_installing_needs_replace() { + use vectorcraft_plugins::permissions::sha256_hex; + use vectorcraft_plugins::registry; + let folder = crate::tests_links::Folder::new("plugin-store-installed-while-installing"); + let id = "test.engine.installedwhileinstalling"; + let ours = asking(id, "[]", &json!({"objects": []})); + let theirs = asking(id, "[]", &json!({"objects": [], "documentData": null})); + let data = vectorcraft_format::base64_encode(&ours); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap()); + let installed = || registry::get(id).map(|p| p.hash().to_string()); + let meanwhile = |theirs: Vec| -> Option> { + Some(Box::new(move || { + registry::install_bytes(&theirs).unwrap(); + })) + }; + + for (replace, why) in [(Value::Null, "is another module than"), (json!(false), "is already installed")] { + crate::cmd::plugin::BEFORE_INSTALL.set(meanwhile(theirs.clone())); + let e = s.execute("plugin.install", &json!({"dataBase64": data, "replace": replace})).unwrap_err().to_string(); + assert!(e.contains(why), "{replace}: {e}"); + assert!(!module.exists(), "{replace}: no module is stored"); + assert_eq!(installed(), Some(sha256_hex(&theirs)), "{replace}: the other plug-in stays installed"); + registry::remove(id); + } + + crate::cmd::plugin::BEFORE_INSTALL.set(meanwhile(theirs)); + run(&mut s, "plugin.install", json!({"dataBase64": data, "replace": true})); + assert_eq!(std::fs::read(&module).unwrap(), ours); + assert_eq!(installed(), Some(sha256_hex(&ours))); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// Without a plug-in store, the install's approval comes from the grant it read first, the one it +/// checks again before it installs: a grant another session records after that read approves +/// nothing, so a revoke that follows it can't be undone by the install recording it again. +#[test] +fn a_grant_recorded_and_revoked_while_installing_approves_nothing() { + use vectorcraft_plugins::permissions::{Grant, GrantToken, Permission, sha256_hex}; + use vectorcraft_plugins::registry; + let id = "test.engine.permissions.recordedwhileinstalling"; + let bytes = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let data = vectorcraft_format::base64_encode(&bytes); + let mut s = session(); + let g = Grant { id: id.into(), hash: sha256_hex(&bytes), permissions: vec![Permission::DocRead] }; + crate::cmd::plugin::AFTER_GRANT_READ.set(Some(Box::new(move || { + registry::grant(GrantToken::after_engine_approval(g)); + }))); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("asks for the permissions doc.read"), "{e}"); + assert!(registry::get(id).is_none(), "nothing is installed"); + registry::revoke(id); +} + +/// A `plugin.remove` in another session while a module is installed waits until the install has +/// written the store, then removes the module, its grant and the plug-in: nothing of it stays in +/// the store for the next launch. +#[test] +fn a_removal_while_installing_leaves_nothing_in_the_store() { + use vectorcraft_plugins::registry; + let folder = crate::tests_links::Folder::new("plugin-store-removed-while-installing"); + let id = "test.engine.permissions.removedwhileinstalling"; + let bytes = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let data = vectorcraft_format::base64_encode(&bytes); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap()); + let grants = folder.0.join("grants.json"); + + let (said, heard) = std::sync::mpsc::channel::<&'static str>(); + let (handed, remover) = std::sync::mpsc::channel(); + let dir = folder.0.clone(); + crate::cmd::plugin::BEFORE_STORE.set(Some(Box::new(move || { + let thread = std::thread::spawn(move || { + let tell = said.clone(); + crate::cmd::plugin::WHILE_WAITING.set(Some(Box::new(move || { + let _ = tell.send("waiting"); + }))); + let mut other = session(); + other.attach_plugin_store(dir); + let removed = other.execute("plugin.remove", &json!({"id": id})).map(|_| ()).map_err(|e| e.to_string()); + let _ = said.send("removed"); + removed + }); + // The removal has finished, or waits for this install. + let first = heard.recv().unwrap_or("gone"); + let _ = handed.send((first, thread)); + }))); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]})); + let (first, thread) = remover.recv().unwrap(); + assert_eq!(thread.join().unwrap(), Ok(())); + assert!(registry::get(id).is_none() && registry::granted(id).is_none(), "the plug-in and its grant are gone"); + assert!(!module.exists(), "no module stays in the store"); + assert!(!std::fs::read_to_string(&grants).is_ok_and(|g| g.contains(id)), "no grant stays in the store"); + assert_eq!(first, "waiting", "the removal waits for the install"); +} + +/// Another session that attaches the plug-in store while a new version is installed loads the +/// stored plug-ins once the install has written the store: the version that runs is the one the +/// store keeps for the next launch. +#[test] +fn a_store_loaded_while_installing_loads_the_new_version() { + use vectorcraft_plugins::permissions::sha256_hex; + use vectorcraft_plugins::registry; + let folder = crate::tests_links::Folder::new("plugin-store-loaded-while-installing"); + let id = "test.engine.permissions.loadedwhileinstalling"; + let old = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let new = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "documentData": null})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&old), "grant": ["doc.read"]})); + + let (said, heard) = std::sync::mpsc::channel::<&'static str>(); + let (handed, loader) = std::sync::mpsc::channel(); + let dir = folder.0.clone(); + crate::cmd::plugin::BEFORE_STORE.set(Some(Box::new(move || { + let thread = std::thread::spawn(move || { + let tell = said.clone(); + crate::cmd::plugin::WHILE_WAITING.set(Some(Box::new(move || { + let _ = tell.send("waiting"); + }))); + session().attach_plugin_store(dir); + let _ = said.send("loaded"); + }); + // The load has finished, or waits for this install. + let first = heard.recv().unwrap_or("gone"); + let _ = handed.send((first, thread)); + }))); + let data = vectorcraft_format::base64_encode(&new); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"], "replace": true})); + let (first, thread) = loader.recv().unwrap(); + thread.join().unwrap(); + assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&new)), "the new version runs"); + assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), new, "the store keeps the new version"); + assert_eq!(first, "waiting", "the load waits for the install"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// Runs the test `name` of this module again in a process of its own → whether this is that /// process. The list of disabled plug-ins is kept for the whole process, and the other tests here /// read it again each time they attach a plug-in store. diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 14669fd357..cc99550b78 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -2473,6 +2473,65 @@ fn deferred_commit_forwards_preview_warnings() { assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("type"))), "{ui:?}"); } +/// A session with a rectangle selected, and type too with `with_type`, tool `tool` chosen, +/// Real-time Drawing off, and an interaction labeled `label` whose deferred Bloat preview warns +/// that type is skipped when type is selected. +fn deferred_bloat(label: &str, with_type: bool, tool: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + let text = s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Hi"})).unwrap(); + let ids = if with_type { json!([rect["id"], text["id"]]) } else { json!([rect["id"]]) }; + s.execute("select.set", &json!({ "ids": ids })).unwrap(); + s.select_tool(tool, ViewInfo::default()).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + s.begin_interaction(label).unwrap(); + let p = json!({"tool": "bloat", "points": [[290.0, 200.0]], "width": 200.0, "height": 200.0}); + s.doc_mut().unwrap().interaction.as_mut().unwrap().preview = Some(("object.liquify".into(), p)); + s +} + +/// A command that applies a deferred preview before it runs (Undo, Clear History) reports the +/// preview's warning in its result, as the tool's own commit reports it; the preview is its own +/// step, which Undo takes back. A preview without a warning adds none. +#[test] +fn settling_a_deferred_preview_keeps_its_warning() { + let mut s = deferred_bloat("Bloat", true, "selection"); + let before = s.doc().unwrap().history.undo.len(); + let r = s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(r["undone"], "Bloat", "{r}"); + assert!(r["warning"].as_str().is_some_and(|w| w.contains("type")), "{r}"); + assert_eq!(r["previewWarning"], r["warning"], "{r}"); + assert_eq!(s.doc().unwrap().history.undo.len(), before, "the preview was a step of its own, and Undo took it back"); + let r = s.execute("edit.redo", &json!({})).unwrap(); + assert!(r.get("warning").is_none() && r.get("previewWarning").is_none(), "nothing was settled: {r}"); + + let mut s = deferred_bloat("Bloat", true, "selection"); + let r = s.execute("edit.clearHistory", &json!({})).unwrap(); + assert!(r["warning"].as_str().is_some_and(|w| w.contains("type")), "{r}"); + + let mut s = deferred_bloat("Bloat", false, "selection"); + let r = s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(r, json!({"undone": "Bloat"})); +} + +/// Typing kept as its own step when a tool's drag begins, or when a Cmd press lends the Selection +/// tool, shows its applied preview's warning in the status bar. +#[test] +fn settling_typing_for_a_tool_shows_its_preview_warning() { + use vectorcraft_tools::{Action, Mods}; + let warned = |ui: &[UiRequest]| ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("type"))); + let mut s = deferred_bloat("Typing", true, "selection"); + let ui = s.apply_actions(vec![Action::Begin("Move".into())]).unwrap(); + assert!(warned(&ui), "{ui:?}"); + + let mut s = deferred_bloat("Typing", true, "rectangle"); + let cmd = Mods { cmd: true, ..Default::default() }; + let ui = s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 380.0).with_mods(cmd), ViewInfo::default()).unwrap(); + assert!(warned(&ui), "{ui:?}"); + s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 380.0).with_mods(cmd), ViewInfo::default()).unwrap(); +} + /// File Handling › Use Low Resolution Proxy for Linked EPS (#394): off by default; toggling it does /// not replace full-resolution pixels already in the document. #[test] diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 4707ae98fd..90e7822ef8 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -1348,3 +1348,159 @@ fn theme_picker_refuses_bad_params() { assert!(e.contains(msg), "{p}: {e}"); } } + +const RED: &str = "rgb 255 0 0"; +const PURPLE: &str = "rgb 128 0 204"; +const BLUE: &str = "rgb 0 0 255"; + +/// Two red rectangles, a purple one and a blue one, selected. +fn prominent_art(s: &mut Session) -> [NodeId; 4] { + let ids = [rect(s, json!("#ff0000")), rect(s, json!("#ff0000")), rect(s, json!("#8000cc")), rect(s, json!("#0000ff"))]; + select_all(s); + ids +} + +#[test] +fn weigh_gives_each_rows_share_of_the_uses() { + let mut s = session(); + prominent_art(&mut s); + let map = json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": BLUE, "to": null, "tint": 50}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map})); + assert_eq!(r["shares"], json!([75.0, 25.0])); + assert_eq!( + r["map"], + json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": [BLUE], "to": null, "tint": 50}]), + "rows as given, from as a list" + ); + // An excluded row has no share, and the others share the uses. + let excl = json!([{"from": [RED, PURPLE], "to": null}, {"from": [BLUE], "to": null, "exclude": true}]); + assert_eq!(run(&mut s, "recolor.weigh", json!({"map": excl}))["shares"], json!([100.0, 0.0])); + // An excluded row before another that lists the same colour doesn't take its uses. + let first = json!([{"from": [RED], "exclude": true}, {"from": [RED]}, {"from": [BLUE, PURPLE]}]); + assert_eq!(run(&mut s, "recolor.weigh", json!({"map": first}))["shares"], json!([0.0, 50.0, 50.0])); + // A current colour that no art uses counts nothing, and a use counts for the first row only. + let twice = json!({"map": [{"from": [RED, "#123456"]}, {"from": [RED, PURPLE, BLUE]}]}); + assert_eq!(run(&mut s, "recolor.weigh", twice)["shares"], json!([50.0, 50.0])); + // As recolor.apply matches them: an exact colour before a hex one, a row with a new colour first. + let exact = json!({"map": [{"from": ["#ff0000", PURPLE], "to": "#00ff00"}, {"from": [RED, BLUE], "to": "#ffff00"}]}); + assert_eq!(run(&mut s, "recolor.weigh", exact)["shares"], json!([25.0, 75.0])); + let recolored = json!({"map": [{"from": [RED, PURPLE]}, {"from": [RED, BLUE], "to": "#ffff00"}]}); + assert_eq!(run(&mut s, "recolor.weigh", recolored)["shares"], json!([25.0, 75.0])); +} + +#[test] +fn weigh_moves_colors_to_the_rows_weights_and_changes_nothing() { + let mut s = session(); + let [_, _, purple, blue] = prominent_art(&mut s); + let map = json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": [BLUE], "to": "#ffff00"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 1]})); + assert_eq!(r["map"], json!([{"from": [RED], "to": "#00ff00"}, {"from": [BLUE, PURPLE], "to": "#ffff00"}])); + assert_eq!(r["shares"], json!([50.0, 50.0])); + assert_eq!(fill(&s, purple).to_hex(), "#8000cc", "a query leaves the art alone"); + // Recoloring with the weighed rows gives the moved colour its new row's colour. + select_all(&mut s); + run(&mut s, "recolor.apply", json!({"map": r["map"]})); + assert_eq!(fill(&s, purple).to_hex(), "#ffff00"); + assert_eq!(fill(&s, blue).to_hex(), "#ffff00"); + // A row's first colour stays, however small its weight. + let mut s = session(); + prominent_art(&mut s); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [0, 1]})); + assert_eq!(r["map"][0]["from"], json!([RED])); + assert_eq!(r["shares"], json!([50.0, 50.0])); + // Excluded rows keep their colours and take none; their weights are ignored. + let three = + json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE], "to": "#000000", "exclude": true}, {"from": ["#00ff00"], "to": "#000000"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": three, "weights": [1, 50, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE])); + // A row without a new colour recolors nothing, so it neither gives nor takes colours. + let keep = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE]}, {"from": ["#00ff00"], "to": "#000000"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": keep, "weights": [0, 1, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE])); +} + +/// A colour that two rows list moves only where recolor.apply recolors it with the row it moved +/// to, so the shares describe the recolor. +#[test] +fn weigh_keeps_a_moved_color_from_rows_that_also_list_it() { + const GREEN: &str = "rgb 0 255 0"; + let mut s = session(); + let [_, _, purple, _] = prominent_art(&mut s); + rect(&mut s, json!("#00ff00")); + select_all(&mut s); + // The purple moves from the first row to the third; the second row's entry for it goes. + let map = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE, PURPLE], "to": "#ffffff"}, {"from": [GREEN], "to": "#ffff00"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [2, 1, 2]})); + assert_eq!(r["map"][0]["from"], json!([RED])); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + assert_eq!(r["map"][2]["from"], json!([GREEN, PURPLE])); + assert_eq!(r["shares"], json!([40.0, 20.0, 40.0])); + run(&mut s, "recolor.apply", json!({"map": r["map"], "method": "exact"})); + assert_eq!(fill(&s, purple).to_hex(), "#ffff00", "the purple takes the row it moved to"); + // A colour that another row lists first stays: that row would take it back. + let mut s = session(); + let [_, _, purple, _] = prominent_art(&mut s); + rect(&mut s, json!("#00ff00")); + select_all(&mut s); + let map = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [PURPLE, BLUE], "to": "#ffffff"}, {"from": [GREEN], "to": "#ffff00"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [2, 1, 2]})); + assert_eq!(r["map"][0]["from"], json!([RED, PURPLE])); + assert_eq!(r["map"][1]["from"], json!([PURPLE, BLUE])); + assert_eq!(r["shares"], json!([60.0, 20.0, 20.0])); + run(&mut s, "recolor.apply", json!({"map": r["map"], "method": "exact"})); + assert_eq!(fill(&s, purple).to_hex(), "#000000", "the purple keeps the first row's colour"); +} + +#[test] +fn weigh_moves_the_tints_of_a_global_swatch_together() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 90, "k": 0}, "global": true})); + rect(&mut s, json!("#ff0000")); + let full = rect(&mut s, json!("#00ff00")); + let tint = rect(&mut s, json!("#00ff00")); + rect(&mut s, json!("#0000ff")); + run(&mut s, "paint.setFill", json!({"ids": [full.0], "swatch": "Brand"})); + run(&mut s, "paint.setFill", json!({"ids": [tint.0], "swatch": "Brand", "tint": 50})); + select_all(&mut s); + let brand: Vec = run(&mut s, "recolor.colors", json!({}))["colors"] + .as_array() + .unwrap() + .iter() + .filter(|c| c["swatch"] == "Brand") + .map(|c| c["key"].clone()) + .collect(); + assert_eq!(brand.len(), 2); + let map = json!([{"from": [RED, brand[0], brand[1]], "to": "#000000"}, {"from": [BLUE], "to": "#ffffff"}]); + // Even weights would take one tint alone, which would split the swatch: nothing moves. + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + // The swatch's two colours move to the blue row as one. + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 3]})); + assert_eq!(r["map"][1]["from"], json!([BLUE, brand[0], brand[1]])); + assert_eq!(r["shares"], json!([25.0, 75.0])); + // Colours that stay keep their order, a swatch's colours with another between them included. + let split = json!([{"from": [brand[0], RED, brand[1]], "to": "#000000"}, {"from": [BLUE], "to": "#ffffff"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": split, "weights": [3, 1]})); + assert_eq!(r["map"][0]["from"], json!([brand[0], RED, brand[1]])); +} + +#[test] +fn weigh_refuses_bad_weights() { + let mut s = session(); + prominent_art(&mut s); + let map = json!([{"from": [RED]}, {"from": [BLUE], "exclude": true}]); + for (p, msg) in [ + (json!({"map": map, "weights": [1]}), "`weights` must be a list of 2 numbers, one per row"), + (json!({"map": map, "weights": "1, 1"}), "`weights` must be a list of 2 numbers"), + (json!({"map": map, "weights": [1, -1]}), "each weight must be a number of 0 or more (got -1)"), + (json!({"map": map, "weights": [1, "2"]}), "each weight must be a number of 0 or more"), + (json!({"map": map, "weights": [0, 5]}), "at least one row that isn't excluded needs a weight above 0"), + (json!({"weights": [1, 1]}), "missing `map`"), + (json!({"map": [{"to": "#ff0000"}]}), "each row needs `from`"), + ] { + let e = s.execute("recolor.weigh", &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + } +} diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs index 5edec3b1c9..a25b0489ba 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -202,6 +202,52 @@ fn an_error_in_code_an_earlier_run_kept_is_at_that_runs_file_and_line() { let _ = std::fs::remove_dir_all(base); } +/// `$.fileName` in code an earlier run of a named engine kept names the file that code is in: that +/// run's script, a file it included or a file `$.evalFile` gave, not the file of the run that calls +/// it. Code from a script given as text, and code whose place the engine no longer keeps, has no +/// name; a dropped engine takes its functions with it. +#[test] +fn a_kept_function_reads_the_file_name_of_the_file_it_is_in() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-file-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for dir in ["a", "b"] { + std::fs::create_dir_all(base.join(dir)).unwrap(); + } + std::fs::write(base.join("a/inc.jsxinc"), "function fromInclude() { return $.fileName; }").unwrap(); + std::fs::write(base.join("a/ev.jsxinc"), "\nfunction fromEval() { return $.fileName; }").unwrap(); + let def = base.join("a/def.jsx"); + std::fs::write(&def, "#targetengine e\nfunction where() { return $.fileName; }\n#include \"inc.jsxinc\"\n$.evalFile('ev.jsxinc');").unwrap(); + let caller = base.join("b/caller.jsx"); + let call = |s: &mut Session, text: &str| -> Vec { + std::fs::write(&caller, text).unwrap(); + let r = s.execute("script.run", &json!({"path": caller.to_string_lossy()})).unwrap(); + r["log"][0].as_array().unwrap().iter().map(|v| v.as_str().unwrap().replace('\\', "/")).collect() + }; + let mut s = Session::new(); + s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap(); + let from_file = call(&mut s, "#targetengine e\nvc.log([where(), fromInclude(), fromEval(), $.fileName]);"); + let from_text = log(&mut s, "#targetengine e\nvc.log([where(), $.fileName, eval('where()'), eval('$.fileName')]);"); + run(&mut s, "#targetengine e\nfunction fromText() { return $.fileName; }").unwrap(); + let text_kept = call(&mut s, "#targetengine e\nvc.log([fromText(), $.fileName]);"); + // Each run's own place takes the room of the ones before, so def.jsx's places go. + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(300))); + for _ in 0..20 { + run(&mut s, "#targetengine e\nvar x = 1;").unwrap(); + } + let evicted = call(&mut s, "#targetengine e\nvc.log([where(), fromEval(), $.fileName]);"); + TEST_KEPT_MAP_BYTES.with(|b| b.set(None)); + s.execute("script.engine.drop", &json!({"name": "e"})).unwrap(); + let dropped = log(&mut s, "#targetengine e\nvc.log(typeof where);"); + let _ = std::fs::remove_dir_all(&base); + let ends = |names: &[String], ends: &[&str]| names.len() == ends.len() && names.iter().zip(ends).all(|(n, e)| n.ends_with(e)); + assert!(ends(&from_file, &["a/def.jsx", "a/inc.jsxinc", "a/ev.jsxinc", "b/caller.jsx"]), "{from_file:?}"); + let shown: Vec = from_text[0].as_array().unwrap().iter().map(|v| v.as_str().unwrap().replace('\\', "/")).collect(); + assert!(ends(&shown, &["a/def.jsx", "", "a/def.jsx", ""]) && shown[1].is_empty() && shown[3].is_empty(), "{shown:?}"); + assert!(text_kept[0].is_empty() && text_kept[1].ends_with("b/caller.jsx"), "{text_kept:?}"); + assert!(evicted[0].is_empty() && evicted[1].is_empty() && evicted[2].ends_with("b/caller.jsx"), "{evicted:?}"); + assert_eq!(dropped, json!(["undefined"])); +} + /// A named engine keeps the places of its earlier runs' code within its budget, the oldest going /// first, and the code still runs once its place is gone. #[test] diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs index 1d487e44bf..1ad659cfdd 100644 --- a/crates/engine/src/tests/script_include.rs +++ b/crates/engine/src/tests/script_include.rs @@ -374,6 +374,48 @@ fn script_eval_file_returns_last_value_and_shares_globals() { let _ = std::fs::remove_dir_all(base); } +/// `$.fileName` names the file the code reading it is in: a function `$.evalFile` gave names its +/// file after the file has returned, through nested files, and code an included file inserted names +/// that file. Code that `eval` compiled names the file being run. +#[test] +fn script_file_name_names_the_file_of_the_code_that_reads_it() { + let base = folder("filename"); + write(&base.join("main/inc.jsxinc"), "function fromInclude() { return $.fileName; }\nvar includedTop = $.fileName;"); + write( + &base.join("main/sub/outer.jsx"), + "function fromOuter() { return $.fileName; }\nvar before = $.fileName;\n$.evalFile('inner.jsx');\nvar after = $.fileName;\nfromInner();", + ); + write(&base.join("main/sub/inner.jsx"), "function fromInner() { return $.fileName; }\n$.fileName;"); + let r = run( + &base, + "#include \"inc.jsxinc\"\nvar nested = $.evalFile('sub/outer.jsx');\n\ + vc.log([fromInclude(), includedTop, before, after, nested, fromOuter(), fromInner(), $.fileName, eval('$.fileName')]);", + ) + .unwrap(); + let names: Vec = r["log"][0].as_array().unwrap().iter().map(|v| v.as_str().unwrap().replace('\\', "/")).collect(); + let ends = [ + "main/inc.jsxinc", + "main/inc.jsxinc", + "sub/outer.jsx", + "sub/outer.jsx", + "sub/inner.jsx", + "sub/outer.jsx", + "sub/inner.jsx", + "main/main.jsx", + "main/main.jsx", + ]; + assert_eq!(names.len(), ends.len(), "{names:?}"); + for (name, end) in names.iter().zip(ends) { + assert!(name.ends_with(end), "{name} ends with {end}: {names:?}"); + } + // A script given as text has no name, and neither have the functions it defines. + let r = Session::new() + .execute("script.run", &json!({"source": "function f() { return $.fileName; }\nvc.log([f(), $.fileName]);", "name": "t.js"})) + .unwrap(); + assert_eq!(r["log"], json!([["", ""]])); + let _ = std::fs::remove_dir_all(base); +} + #[test] fn script_eval_file_outside_scope_refused() { let base = folder("evaloutside"); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index ad1549140f..fa50c5ce6a 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -75,6 +75,15 @@ fn nine_slice_guide_preview(cmd: &str, p: &Value) -> bool { && p.as_object().is_some_and(|m| m.contains_key("nineSliceGuides") && m.keys().all(|k| k == "name" || k == "nineSliceGuides")) } +/// `ui` with a status message first for each of `warnings` (which came before its requests) that it +/// doesn't already show. +fn with_status(mut ui: Vec, warnings: Vec) -> Vec { + let new: Vec = + warnings.into_iter().filter(|w| !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w))).map(UiRequest::Status).collect(); + ui.splice(0..0, new); + ui +} + /// Requests from tools that only the frontend can fulfil. #[derive(Clone, Debug, PartialEq)] pub enum UiRequest { @@ -227,24 +236,28 @@ impl Session { /// A press with Cmd held (Ctrl elsewhere) and any other tool than a selection tool: the /// selection tool [`Self::lent_tool`] takes the gesture, pressing without Cmd (a plain click, - /// not Select Behind). The tool lent to stays as it was, unaware, until the release. - fn lend_selection_tool(&mut self, ev: &PointerEvent) -> PointerEvent { + /// not Select Behind). The tool lent to stays as it was, unaware, until the release. → the event + /// for the tool, and the warnings of a preview that keeping the typing applied. + fn lend_selection_tool(&mut self, ev: &PointerEvent) -> Result<(PointerEvent, Vec)> { if ev.kind != PointerKind::Down || !ev.mods.cmd || self.lender.is_some() { - return *ev; + return Ok((*ev, vec![])); } - let Some(id) = self.lent_tool(self.tool.id(), &ev.mods) else { return *ev }; + let Some(id) = self.lent_tool(self.tool.id(), &ev.mods) else { return Ok((*ev, vec![])) }; // Typing in progress is kept as its own step first: the lent tool's drag previews from the - // document its interaction started from, which would drop the typing (#1881). + // document its interaction started from, which would drop the typing (#1881). When it + // can't be kept, the press is not lent. + let mut settled = vec![]; if crate::cmd::typing_in_progress(self) { - // Fails only without a document, and then there is no typing to keep. - let _ = crate::cmd::settle_interaction(self); + let (r, warnings) = self.collect_settled_warnings(crate::cmd::settle_interaction); + r?; + settled = warnings; } // The Selection and Group Selection tools act on the instance whole. self.select_whole_instance(id); let lent = self.make_tool(id); self.selection_at_lend = self.active().map(|d| (d.uid, d.selection.clone())); self.lender = Some(std::mem::replace(&mut self.tool, lent)); - PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev } + Ok((PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }, settled)) } /// The lent selection tool's gesture is over: the tool it was lent to is back as it was (the @@ -659,7 +672,8 @@ impl Session { } self.leave_draw_inside(ev, view)?; self.tell_tool_history_moved(); - let ev = &self.lend_selection_tool(ev); + let (ev, settled) = self.lend_selection_tool(ev)?; + let ev = &ev; let lent = self.lender.is_some(); let mut acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev)); if !keep { @@ -670,7 +684,7 @@ impl Session { if keep && ev.kind == PointerKind::Up { self.keep_tool_settings(); } - self.apply_actions(acts) + self.apply_actions(acts).map(|ui| with_status(ui, settled)) }); // The tool's own edits do not move the history for it. A selection tool that Cmd lends // edits for the lender, which hears of the edit at its next event. @@ -1375,7 +1389,14 @@ impl Session { /// Apply tool actions. Errors from previews are reported but keep the interaction alive. pub fn apply_actions(&mut self, acts: Vec) -> Result> { let outer = std::mem::replace(&mut self.in_tool_actions, true); - let r = self.apply_tool_actions(acts); + // A preview that settling applied (typing kept as its own step before a drag begins) shows + // its warning in the status bar, as the tool's own commit shows its preview's. + let r = if !outer && self.depth == 0 { + let (r, settled) = self.collect_settled_warnings(|s| s.apply_tool_actions(acts)); + r.map(|ui| with_status(ui, settled)) + } else { + self.apply_tool_actions(acts) + }; self.in_tool_actions = outer; if !outer && self.depth == 0 { self.trim_histories(); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 8c4e95f4e6..b46b962ce4 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -1038,7 +1038,7 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { /// The documents [`the_writer_counts_objects_as_the_reader_reads_them`] writes: groups, compound /// paths, guides, sublayers, clipping groups and layers, painted clipping paths, opacity masks, /// gradients, pattern definitions and tiles, images, type, symbol definitions and instances, -/// objects with art styles and objects bound to variables. +/// objects with art styles, objects bound to variables and live envelopes. fn counted() -> Vec<(&'static str, Document)> { vec![ ("sample", sample()), @@ -1053,6 +1053,7 @@ fn counted() -> Vec<(&'static str, Document)> { ("painted clips", painted_clips()), ("blend", blend_doc()), ("brushes", brushes_document()), + ("envelopes", envelopes_document()), ] } @@ -9055,3 +9056,446 @@ fn spread_gradients_past_the_stops_the_reader_reads_stay_gradients() { let Some(Paint::Gradient(swatch)) = back.swatch("Bands").map(|s| &s.paint) else { panic!("the swatch is lost") }; assert!((200..=crate::import::MAX_GRADIENT_STOPS).contains(&swatch.gradient.stops.len()), "{} stops", swatch.gradient.stops.len()); } + +/// An envelope named `Bent` of `kind` around a red rectangle (40, 20)-(100, 80) and a blue one +/// (100, 20)-(160, 80), with Fidelity `fidelity`, Edit Contents `editing` and `options`. +fn envelope_node( + doc: &mut Document, + kind: vectorcraft_doc::live::EnvelopeKind, + fidelity: f64, + editing: bool, + options: vectorcraft_doc::live::EnvelopeOptions, +) -> Node { + let content = vec![ + Arc::new(square(doc, Rect::new(40.0, 20.0, 100.0, 80.0), Color::rgb(1.0, 0.0, 0.0))), + Arc::new(square(doc, Rect::new(100.0, 20.0, 160.0, 80.0), Color::rgb(0.0, 0.0, 1.0))), + ]; + let kind = NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing, options, frame: Box::default() }; + named(Node::new(doc.alloc_id(), kind), "Bent") +} + +/// A 200 × 100 document of envelope `kind` ([`envelope_node`]) alone. +fn envelope_doc( + kind: vectorcraft_doc::live::EnvelopeKind, + fidelity: f64, + editing: bool, + options: vectorcraft_doc::live::EnvelopeOptions, +) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let envelope = envelope_node(&mut doc, kind, fidelity, editing, options); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + doc +} + +/// A grid of `rows`×`cols` patches over (30, 10)-(170, 90), row by row from the top left, with its +/// sixth point moved. +fn envelope_points(rows: u32, cols: u32) -> Vec { + let mut points: Vec = (0..=rows) + .flat_map(|r| { + (0..=cols).map(move |c| kurbo::Point::new(30.0 + 140.0 * f64::from(c) / f64::from(cols), 10.0 + 80.0 * f64::from(r) / f64::from(rows))) + }) + .collect(); + points[5] += kurbo::Vec2::new(5.0, -8.0); + points +} + +/// The envelopes written live: warps (vertical, with distortions, and twisted), meshes (with +/// handles of their own, one handle uneven, and with the smooth ones), top objects (a curved top +/// side, and two segments to the top and bottom sides), with Edit Contents off and on. +fn live_envelope_kinds() -> Vec<(&'static str, vectorcraft_doc::live::EnvelopeKind)> { + use vectorcraft_doc::live::{EnvelopeKind, H_RIGHT, WARP_NET, smooth_handles}; + let warp = |style: &str, bend: f64, h: f64, v: f64, horizontal: bool| EnvelopeKind::Warp { + style: style.into(), + bend, + h, + v, + horizontal, + version: WARP_NET, + }; + let points = envelope_points(2, 3); + let mut handles = smooth_handles(2, 3, &points); + handles[5][H_RIGHT] += kurbo::Vec2::new(4.0, 6.0); + let mut curved = kurbo::BezPath::new(); + curved.move_to((30.0, 10.0)); + curved.curve_to((70.0, -5.0), (130.0, -5.0), (170.0, 10.0)); + curved.line_to((170.0, 90.0)); + curved.line_to((30.0, 90.0)); + curved.close_path(); + let mut six = kurbo::BezPath::new(); + six.move_to((30.0, 10.0)); + for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (100.0, 95.0), (30.0, 90.0)] { + six.line_to(p); + } + six.close_path(); + vec![ + ("arc", warp("arc", 50.0, -20.0, 30.0, false)), + ("twist", warp("twist", -35.0, 0.0, 0.0, true)), + ("mesh", EnvelopeKind::Mesh { rows: 2, cols: 3, points: points.clone(), handles }), + ("smooth mesh", EnvelopeKind::Mesh { rows: 2, cols: 3, points, handles: vec![] }), + ("curved top object", EnvelopeKind::TopObject { path: PathData::from_bezpath(&curved), corners: None }), + ("six-anchor top object", EnvelopeKind::TopObject { path: PathData::from_bezpath(&six), corners: None }), + ] +} + +/// `doc` written and read back from its editing data: the top object on its first layer, the +/// writer's warnings and the reader's notes. +fn envelope_back(doc: &Document) -> (Node, Vec, Vec) { + let (p, warnings) = private_data(doc, &opts()).unwrap(); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let a = back.layers[0].children().unwrap(); + assert_eq!(a.len(), 1, "{a:#?}"); + ((*a[0]).clone(), warnings, notes) +} + +/// Does reopened envelope kind `got` have the shape of `want`: a warp's style, bend, distortions +/// and orientation; a mesh's grid and the handles that draw (those inside the grid); the patch a top +/// object's outline makes? +fn same_envelope_kind(want: &vectorcraft_doc::live::EnvelopeKind, got: &vectorcraft_doc::live::EnvelopeKind) -> bool { + use vectorcraft_doc::live::{Coons, EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, smooth_handles}; + let near = |a: kurbo::Point, b: kurbo::Point| a.distance(b) < 1e-3; + match (want, got) { + ( + EnvelopeKind::Warp { style, bend, h, v, horizontal, version }, + EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal: hz, version: ver }, + ) => style == s && horizontal == hz && version == ver && [(bend, b), (h, dh), (v, dv)].iter().all(|(x, y)| (*x - *y).abs() < 1e-9), + (EnvelopeKind::Mesh { rows, cols, points, handles }, EnvelopeKind::Mesh { rows: r, cols: c, points: p, handles: hs }) => { + let own = if handles.is_empty() { smooth_handles(*rows, *cols, points) } else { handles.clone() }; + let inside = |i: usize, slot: usize| { + let (row, col) = (i / (*cols as usize + 1), i % (*cols as usize + 1)); + match slot { + s if s == H_LEFT => col > 0, + s if s == H_RIGHT => col < *cols as usize, + s if s == H_UP => row > 0, + s if s == H_DOWN => row < *rows as usize, + _ => false, + } + }; + (rows, cols) == (r, c) + && points.len() == p.len() + && points.iter().zip(p).all(|(a, b)| near(*a, *b)) + && own.iter().zip(hs).enumerate().all(|(i, (a, b))| (0..4).all(|s| !inside(i, s) || near(a[s].to_point(), b[s].to_point()))) + } + (EnvelopeKind::TopObject { path, corners }, EnvelopeKind::TopObject { path: p, corners: c }) => { + let (Some(a), Some(b)) = (Coons::from_top_object(path, *corners), Coons::from_top_object(p, *c)) else { return false }; + (0..=8).all(|j| { + (0..=8).all(|i| a.eval(f64::from(i) / 8.0, f64::from(j) / 8.0).distance(b.eval(f64::from(i) / 8.0, f64::from(j) / 8.0)) < 1e-2) + }) + } + _ => false, + } +} + +/// A PDF-compatible `.ai`'s editing data keeps a warp, a mesh and a top object envelope live, with +/// Edit Contents off and on: VectorCraft's reader opens each as the same envelope, its contents, +/// Fidelity, Edit Contents, Envelope Options and shape as saved, and its name, the drawn art saved +/// with it drawing as the envelope does. +#[test] +fn envelopes_are_read_back_live() { + use vectorcraft_doc::live::EnvelopeOptions; + for (name, kind) in live_envelope_kinds() { + for editing in [false, true] { + let doc = envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW); + let (back, warnings, notes) = envelope_back(&doc); + let what = format!("{name}, editing {editing}"); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}"); + assert!(!notes.iter().any(|w| w == crate::import::ENVELOPE_DRAWN), "{what}: {notes:?}"); + let NodeKind::Envelope { content, kind: got, fidelity, editing: e, options, frame } = &back.kind else { panic!("{what}: {back:#?}") }; + assert_eq!((*fidelity, *e, *options, **frame), (50.0, editing, EnvelopeOptions::NEW, Affine::IDENTITY), "{what}"); + assert!(same_envelope_kind(&kind, got), "{what}: {got:?}"); + assert_eq!(back.name.as_deref(), Some("Bent"), "{what}"); + let colors: Vec> = content.iter().map(|c| c.appearance.fill_paint().color()).collect(); + assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}"); + // Saved again as it opened (a top object now with the corners the file gives it), it + // opens as the same envelope again. + let mut again = Document::new(200.0, 100.0); + let layer = again.layers[0].id; + again.insert(Some(layer), usize::MAX, back.clone()).unwrap(); + let (twice, warnings, notes) = envelope_back(&again); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}, saved again: {warnings:?}"); + let NodeKind::Envelope { kind: twice, .. } = &twice.kind else { panic!("{what}, saved again: {twice:#?} {notes:?}") }; + assert!(same_envelope_kind(&kind, twice), "{what}, saved again: {twice:?}"); + } + } +} + +/// An envelope that is a symbol's art is written live in the symbol's definition, and the symbol +/// opens with it as a live envelope. +#[test] +fn an_envelope_in_a_symbol_is_read_back_live() { + use vectorcraft_doc::live::EnvelopeOptions; + let kinds = live_envelope_kinds(); + for (name, kind) in kinds { + let mut doc = Document::new(200.0, 100.0); + let art = envelope_node(&mut doc, kind.clone(), 50.0, false, EnvelopeOptions::NEW); + doc.symbols.push(Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Bent".into(), xf: Affine::translate((10.0, 5.0)) }); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{name}: {warnings:?}"); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let symbol = back.symbols.iter().find(|s| s.name == "Bent").unwrap_or_else(|| panic!("{name}: {:?}", back.symbols)); + let mut envelopes = vec![]; + symbol.art.walk(&mut |n| { + if let NodeKind::Envelope { kind, .. } = &n.kind { + envelopes.push((**kind).clone()); + } + }); + assert!(matches!(envelopes.as_slice(), [k] if same_envelope_kind(&kind, k)), "{name}: {envelopes:?} {notes:?}"); + } +} + +/// An envelope with an opacity mask comes back live with its mask, and the plugin's `XP` follows +/// the envelope's dictionary rather than its mask's art. +#[test] +fn an_envelope_with_an_opacity_mask_is_read_back_live() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let mut doc = Document::new(200.0, 100.0); + let mut envelope = envelope_node(&mut doc, kind.clone(), 50.0, false, EnvelopeOptions::NEW); + let gray = square(&mut doc, Rect::new(40.0, 20.0, 160.0, 80.0), Color::gray(0.5)); + let art = group(&mut doc, vec![gray], false); + envelope.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + let text = program(&doc); + let (xp, mask) = (text.find(") 1 0 0 XP").unwrap_or(0), text.find("(AI9_Mask)").unwrap_or(usize::MAX)); + assert!(mask < xp, "{text}"); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let NodeKind::Envelope { kind: got, .. } = &back.kind else { panic!("{back:#?} {notes:?}") }; + assert!(same_envelope_kind(&kind, got), "{got:?}"); + assert!(back.extras().mask.is_some(), "{back:#?}"); +} + +/// As many envelopes are written live as the reader checks in one file, and the ones after them +/// as their art, with a warning. +#[test] +fn envelopes_past_the_readers_checks_are_written_as_their_art() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let limit = crate::import::ENVELOPE_CHECKS; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for _ in 0..=limit { + let points = [(30.0, 10.0), (170.0, 0.0), (30.0, 90.0), (170.0, 90.0)].map(kurbo::Point::from).to_vec(); + let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }; + let envelope = envelope_node(&mut doc, kind, 50.0, false, EnvelopeOptions::NEW); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + } + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let a = back.layers[0].children().unwrap(); + let live = a.iter().filter(|n| matches!(n.kind, NodeKind::Envelope { .. })).count(); + assert_eq!((a.len(), live), (limit + 1, limit), "{notes:?}"); + assert!(!notes.iter().any(|n| n == crate::import::ENVELOPE_DRAWN), "{notes:?}"); +} + +/// Every Envelope Options switch and the ends of Fidelity come back as saved. +#[test] +fn envelope_options_and_fidelity_are_read_back() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, PreserveShape}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let new = EnvelopeOptions::NEW; + let all = [ + (new, 50.0), + (EnvelopeOptions { anti_alias: false, ..new }, 0.0), + (EnvelopeOptions { preserve_shape: PreserveShape::Transparency, ..new }, 100.0), + (EnvelopeOptions { distort_linear_gradients: true, distort_pattern_fills: true, ..new }, 30.0), + (EnvelopeOptions { distort_appearance: false, ..new }, 50.0), + ]; + for (options, fidelity) in all { + let (back, _, notes) = envelope_back(&envelope_doc(kind.clone(), fidelity, false, options)); + let NodeKind::Envelope { options: o, fidelity: f, .. } = &back.kind else { panic!("{options:?}: {back:#?} {notes:?}") }; + assert_eq!((*o, *f), (options, fidelity)); + } +} + +/// With Distort Appearance, an envelope whose contents map from their bounds with their strokes +/// saves that box's size, and opens mapping from the same box. +#[test] +fn an_envelope_mapping_from_its_contents_strokes_is_read_back() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, envelope_content_boxes, envelope_stroke_margin}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut envelope = envelope_node( + &mut doc, + EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }, + 50.0, + false, + EnvelopeOptions::NEW, + ); + let NodeKind::Envelope { content, options, .. } = &mut envelope.kind else { unreachable_envelope() }; + for c in content.iter_mut() { + let fill = Arc::make_mut(c).appearance.fill_paint(); + Arc::make_mut(c).appearance = Appearance::basic(fill, solid(Color::BLACK), 8.0).into(); + } + let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY).unwrap(); + options.bounds_margin = envelope_stroke_margin(stroked, plain).unwrap(); + let margin = options.bounds_margin; + assert!(margin.iter().all(|m| *m > 0.0), "{margin:?}"); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let NodeKind::Envelope { options, .. } = &back.kind else { panic!("{back:#?} {notes:?}") }; + assert!(options.bounds_margin.iter().zip(margin).all(|(a, b)| (a - b).abs() < 1e-4), "{:?} {margin:?}", options.bounds_margin); +} + +/// A test's stand-in for a node that must be an envelope. +fn unreachable_envelope() -> ! { + panic!("not an envelope") +} + +/// The record is written in the order the reader reads it: on `%_` lines the group holding the +/// grid, its dictionary with the options, the contents' block and the kind, then the distorted art +/// on plain lines and the plugin's `XP`. While the contents are edited, the group holds them and +/// the dictionary the grid. +#[test] +fn an_envelope_is_written_in_the_order_the_reader_reads_it() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + for editing in [false, true] { + let text = program(&envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW)); + let at = |s: &str| text.find(s).unwrap_or_else(|| panic!("{s} missing: {text}")); + let (group, mesh, dictionary, block, key, style, xp) = ( + at("%_u\n"), + at("%_/Mesh X!"), + at("%_131073 /Int (AIDeformOptionsKey) ,\n%_50 /Int (AIDeformFidelityKey) ,\n"), + at("%_X=\n"), + at("%_ (AIDeformArtKey) ,\n"), + at("%_2 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\n"), + at("(Adobe Deform Plugin) 1 0 0 XP\n"), + ); + assert!(group < dictionary && dictionary < block && block < key && key < style && style < xp, "{text}"); + assert_eq!(block < mesh, editing, "{text}"); + assert!(text.contains(&format!("%_{} /Bool (AIDeformEditModeKey) ,\n", u8::from(editing))), "{text}"); + // The distorted art is one group on plain lines, named as the envelope, right before the `XP`. + let drawn = &text[style..xp]; + assert!(drawn.contains("\nu\n") && drawn.contains("(Bent) ; (AI10_ArtUID) ,"), "{drawn}"); + // The contents are on `%_` lines only: drawn once on plain lines, as the distorted art. + assert_eq!(text.matches("(Adobe Deform Plugin)").count(), 1, "{text}"); + } +} + +/// Envelopes the reader would open in another shape, or not at all, are written as their distorted +/// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn +/// without its style's net, a mesh the reader would lay out the other way round, a top object +/// whose sides don't pair up, a Fidelity that isn't a whole number and bounds margins kept with +/// Distort Appearance off. Other live objects keep their own warning. +#[test] +fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let base = + || envelope_doc(EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }, 50.0, false, EnvelopeOptions::NEW); + let change = |f: &dyn Fn(&mut NodeKind)| { + let mut doc = base(); + let id = doc.layers[0].children().unwrap()[0].id; + f(&mut doc.node_mut(id).unwrap().kind); + doc + }; + let typed = change(&|k| { + if let NodeKind::Envelope { content, .. } = k { + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "Type", vectorcraft_doc::CharStyle::default()); + content.push(Arc::new(Node::new(vectorcraft_doc::NodeId(9_000), NodeKind::Text(Box::new(t))))); + } + }); + let turned = change(&|k| { + if let NodeKind::Envelope { frame, .. } = k { + **frame = Affine::rotate(0.3); + } + }); + let old_warp = change(&|k| { + if let NodeKind::Envelope { kind, .. } = k { + **kind = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; + } + }); + // Columns down the page and rows across: the reader turns the grid round. + let sideways = change(&|k| { + if let NodeKind::Envelope { kind, .. } = k { + let points = + (0..=3).flat_map(|r| (0..=2).map(move |c| kurbo::Point::new(30.0 + 70.0 * f64::from(c), 10.0 + 80.0 * f64::from(r) / 3.0))).collect(); + **kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: transpose(points, 3, 2), handles: vec![] }; + } + }); + let mut uneven = kurbo::BezPath::new(); + uneven.move_to((30.0, 10.0)); + for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] { + uneven.line_to(p); + } + uneven.close_path(); + let lopsided = change(&|k| { + if let NodeKind::Envelope { kind, .. } = k { + **kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven), corners: None }; + } + }); + let fractional = change(&|k| { + if let NodeKind::Envelope { fidelity, .. } = k { + *fidelity = 50.5; + } + }); + let margin_kept = change(&|k| { + if let NodeKind::Envelope { options, .. } = k { + options.distort_appearance = false; + options.bounds_margin = [0.5; 4]; + } + }); + let cases = [ + ("type", typed), + ("turned", turned), + ("old warp", old_warp), + ("sideways mesh", sideways), + ("lopsided top object", lopsided), + ("fractional fidelity", fractional), + ("margin kept", margin_kept), + ]; + for (what, doc) in cases { + let text = program(&doc); + assert!(!text.contains("AIDeformOptionsKey"), "{what}: {text}"); + let (back, warnings, _) = envelope_back(&doc); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}"); + assert!(!warnings.iter().any(|w| w == writer::LIVE), "{what}: {warnings:?}"); + assert!(matches!(back.kind, NodeKind::Group { .. }), "{what}: {back:#?}"); + } + let blend = export_ai_editing(&blend_doc(), &opts()).unwrap(); + assert!(blend.warnings.iter().any(|w| w == writer::LIVE) && !blend.warnings.iter().any(|w| w == writer::ENVELOPES), "{:?}", blend.warnings); +} + +/// `points`, a grid of `rows + 1` rows of `cols + 1` points, as `cols + 1` rows of `rows + 1`. +fn transpose(points: Vec, rows: usize, cols: usize) -> Vec { + (0..=cols).flat_map(|c| (0..=rows).map(move |r| (r, c))).map(|(r, c)| points[r * (cols + 1) + c]).collect() +} + +/// An envelope with its contents in an opacity mask's art is written as its art there, and an +/// envelope in a group stays live. +#[test] +fn envelopes_in_masks_are_written_as_their_art_and_in_groups_stay_live() { + let doc = envelopes_document(); + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let mut live = 0; + back.walk(|n| live += usize::from(matches!(n.kind, NodeKind::Envelope { .. }))); + assert_eq!(live, 3, "{notes:?}"); +} + +/// A warp, a mesh envelope with its contents edited, a top object in a group, and an envelope in an +/// opacity mask's art. +fn envelopes_document() -> Document { + use vectorcraft_doc::live::EnvelopeOptions; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let kinds = live_envelope_kinds(); + let kind = |name: &str| kinds.iter().find(|(n, _)| *n == name).map(|(_, k)| k.clone()).unwrap(); + let warp = envelope_node(&mut doc, kind("arc"), 50.0, false, EnvelopeOptions::NEW); + let mesh = envelope_node(&mut doc, kind("mesh"), 50.0, true, EnvelopeOptions::NEW); + let top = envelope_node(&mut doc, kind("six-anchor top object"), 50.0, false, EnvelopeOptions::NEW); + let top = group(&mut doc, vec![top], false); + let mut masked = square(&mut doc, Rect::new(0.0, 0.0, 200.0, 100.0), Color::gray(0.5)); + let mask_art = envelope_node(&mut doc, kind("twist"), 50.0, false, EnvelopeOptions::NEW); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, false))); + for n in [warp, mesh, top, masked] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + doc +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index c78c3e6542..cd0455f10f 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -16,6 +16,7 @@ use crate::ai_ops::{self, unit}; use crate::ps; mod brushes; +mod envelopes; mod images; mod paints; mod styles; @@ -25,6 +26,8 @@ mod variables; #[cfg(test)] pub(super) use brushes::BRUSHES; +#[cfg(test)] +pub(super) use envelopes::ENVELOPES; pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; #[cfg(test)] pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance}; @@ -52,8 +55,7 @@ pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fil pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps"; pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps"; pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; -pub(super) const LIVE: &str = - "blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; +pub(super) const LIVE: &str = "blends, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; @@ -262,6 +264,12 @@ pub(super) struct Writer<'a> { brush_set: brushes::Brushes, /// Brushes may be written; false on a large canvas, where they are left out. pub(super) brushes_live: bool, + /// The object just written is an envelope's distorted art, which the plugin's `XP` follows + /// ([`Self::envelope`]). + deform: bool, + /// How many more envelopes may be written live: as many as the reader checks in one file + /// ([`crate::import::ENVELOPE_CHECKS`]). + envelopes_left: usize, } impl<'a> Writer<'a> { @@ -319,6 +327,8 @@ impl<'a> Writer<'a> { in_look: false, brush_set: brushes::Brushes::default(), brushes_live: true, + deform: false, + envelopes_left: crate::import::ENVELOPE_CHECKS, objects: 0, left_out: 0, definition_objects: 0, @@ -694,29 +704,16 @@ impl<'a> Writer<'a> { } self.end_group("U", xy); } - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => { - if !self.open_group("u") { - return; - } - self.warn(LIVE); - if self.nest < MAX_NEST { - let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); - self.nest += 1; - for c in g.children().map(Vec::as_slice).unwrap_or_default() { - self.node(c); - } - self.nest -= 1; - } - self.end_group("U", xy); + NodeKind::Envelope { .. } if self.envelope(n, xy) => {} + NodeKind::Envelope { .. } => self.drawn_art(n, xy, Some(envelopes::ENVELOPES)), + NodeKind::Blend { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { + self.drawn_art(n, xy, Some(LIVE)); } // Ruler guides are kept in the document's native copy only. NodeKind::RulerGuide { .. } => {} } + // Taken before the dictionary, whose opacity mask art may hold envelopes of its own. + let deform = std::mem::take(&mut self.deform); if self.body.len() > start { // A group's dictionary goes between its end and its state block. let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start)); @@ -726,6 +723,29 @@ impl<'a> Writer<'a> { self.body.push_str(&b); } } + // An envelope's plugin `XP` follows its distorted art, after the art's state block. + if deform { + self.line(&envelopes::plugin_line()); + } + } + + /// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given. + fn drawn_art(&mut self, n: &Node, xy: Xy, warning: Option<&str>) { + if !self.open_group("u") { + return; + } + if let Some(w) = warning { + self.warn(w); + } + if self.nest < MAX_NEST { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + self.nest += 1; + for c in g.children().map(Vec::as_slice).unwrap_or_default() { + self.node(c); + } + self.nest -= 1; + } + self.end_group("U", xy); } /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]), diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs new file mode 100644 index 0000000000..dcd7274291 --- /dev/null +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -0,0 +1,395 @@ +//! Envelopes (see [`crate::ai_editing`] for the form), written in the form VectorCraft's `.ai` +//! reader opens as live envelopes again: on `%_` lines a group holding the grid that shapes the +//! envelope (while its contents are edited, the contents), then that group's `/ArtDictionary` with +//! Envelope Options as a bit word (`AIDeformOptionsKey`), Fidelity (`AIDeformFidelityKey`), Edit +//! Contents (`AIDeformEditModeKey`), a warp's style, bend, distortions and orientation +//! (`DeformStyle`, `DeformValue`, `DeformHoriz`, `DeformVert`, `Rotate`), with Distort Appearance +//! the size of the box the contents map from (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`), the +//! contents in an `X=` … `X+` block (while they are edited, the grid) as `AIDeformArtKey`, and the +//! kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object); then the distorted art on plain +//! lines, and the plugin's `XP` after it. A top object's outline is the edge of the grid written +//! for it, one grid edge to each segment of its sides. An envelope the reader would open in another +//! shape is written as its distorted art. + +use std::fmt::Write; +use std::sync::Arc; + +use kurbo::{Point, Vec2}; +use vectorcraft_doc::live::{ + Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes, + envelope_grid, envelope_stroke_margin, warp_grid, +}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Affine, PathData}; + +use super::{MAX_NEST, Writer, Xy, hide}; +use crate::import::{DEFORM_PLUGIN, EnvelopeGridPoint as GridPoint, envelope_outline, envelope_upright, warp_number}; +use crate::ps; + +pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes of type, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; + +/// The most rows or columns of an envelope's grid written (Make with Mesh's own limit, and the +/// reader's). +const MAX_GRID: usize = 50; +/// How far, as a share of the diagonal of a top object's patch, the patch on the outline written for +/// it may lie from its own at any of the places checked. +const TOP_OBJECT_TOLERANCE: f64 = 1e-6; +/// How far apart two bounds margins may be and still count as the same. +const MARGIN_SLACK: f32 = 1e-5; + +/// What an envelope's dictionary holds besides its contents: the entries before the contents' block, +/// each on a line of its own, the kind (`AIDeformStyle`) and the grid that shapes it (`rows`, `cols` +/// and its points with their handles, row by row from the top left, in the document), when it has +/// one. +struct Record { + keys: String, + style: u8, + grid: Option<(usize, usize, Vec)>, +} + +/// The `XP` that ends an envelope, after its distorted art. +pub(super) fn plugin_line() -> String { + format!("{} 1 0 0 XP", ps::bytes(DEFORM_PLUGIN.as_bytes())) +} + +/// Does `n`, or anything in it, hold type? +fn holds_type(n: &Node) -> bool { + matches!(n.kind, NodeKind::Text(_)) || n.children().is_some_and(|c| c.iter().any(|k| holds_type(k))) +} + +/// The record of envelope `n` (its parts given), or `None` where the reader would open it in +/// another shape than it has, or not as an envelope. +fn record( + n: &Node, + content: &[Arc], + kind: &EnvelopeKind, + fidelity: f64, + editing: bool, + options: &EnvelopeOptions, + frame: Affine, +) -> Option { + // The reader reads Fidelity as a whole number from 0 to 100, and bounds margins only with + // Distort Appearance on. + let whole = (0.0..=100.0).contains(&fidelity) && fidelity.fract() == 0.0; + let margin_kept = !options.distort_appearance && options.bounds_margin != [0.0; 4]; + if content.is_empty() || frame != Affine::IDENTITY || !whole || margin_kept || content.iter().any(|c| holds_type(c)) { + return None; + } + let mut keys = String::new(); + let bit = |on: bool, at: u32| i64::from(on) << at; + let word = bit(options.distort_appearance, 0) + | bit(options.distort_pattern_fills, 1) + | bit(options.distort_linear_gradients, 2) + | bit(!options.anti_alias, 16) + | bit(options.preserve_shape == PreserveShape::ClippingMask, 17); + let _ = writeln!(keys, "{word} /Int (AIDeformOptionsKey) ,"); + let _ = writeln!(keys, "{} /Int (AIDeformFidelityKey) ,", fidelity as i64); + let _ = writeln!(keys, "{} /Bool (AIDeformEditModeKey) ,", u8::from(editing)); + let real = |keys: &mut String, v: f64, key: &str| { + ps::push_num_places(keys, v, 9); + let _ = writeln!(keys, " /Real ({key}) ,"); + }; + let (style, grid) = match kind { + EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => { + let fraction = |p: f64| (p.is_finite() && (-100.0..=100.0).contains(&p)).then_some(p / 100.0); + let (number, bend, h, v) = (warp_number(style)?, fraction(*bend)?, fraction(*h)?, fraction(*v)?); + if *version != WARP_NET { + return None; + } + let _ = writeln!(keys, "{number} /Int (DeformStyle) ,"); + real(&mut keys, h, "DeformHoriz"); + real(&mut keys, bend, "DeformValue"); + real(&mut keys, v, "DeformVert"); + let _ = writeln!(keys, "{} /Bool (Rotate) ,", u8::from(!*horizontal)); + // The grid of the warp's shape, for apps that read it. + (1, warp_grid(n).and_then(|m| as_read(&m)).and_then(|(rows, cols, pts)| sized(rows, cols).map(|(r, c)| (r, c, pts)))) + } + EnvelopeKind::Mesh { rows, cols, points, handles } => { + let (r, c) = sized(*rows, *cols)?; + let mesh = envelope_grid(*rows, *cols, points, handles)?; + let pts = as_read(&mesh)?.2; + // The reader lays the grid out with its first row at the top and its first column at the + // left: a grid it would lay out another way round would map the contents another way. + if !reads_as_written(r, c, &pts) { + return None; + } + (2, Some((r, c, pts))) + } + EnvelopeKind::TopObject { path, corners } => (3, Some(top_object_grid(path, *corners)?)), + }; + if options.distort_appearance { + let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY)?; + let margin = options.bounds_margin; + let same = |m: [f32; 4]| m.iter().zip(margin).all(|(a, b)| (a - b).abs() <= MARGIN_SLACK); + // The reader maps from the size written when it matches the contents' bounds with their + // strokes, and from their geometric bounds otherwise. + let size = if margin == [0.0; 4] { + plain + } else if stroked != plain && envelope_stroke_margin(stroked, plain).is_some_and(same) { + stroked + } else { + return None; + }; + real(&mut keys, size.height(), "AIDeformArtHeightKey"); + real(&mut keys, size.width(), "AIDeformArtWidthKey"); + } + Some(Record { keys, style, grid }) +} + +/// `rows` and `cols` as sizes, when the reader reads a grid of them. +fn sized(rows: u32, cols: u32) -> Option<(usize, usize)> { + let (r, c) = (usize::try_from(rows).ok()?, usize::try_from(cols).ok()?); + ((1..=MAX_GRID).contains(&r) && (1..=MAX_GRID).contains(&c)).then_some((r, c)) +} + +/// The points of grid `m` with their handles as the reader reads them back: the handles that point +/// out of the grid, which nothing draws with, are left at the point. `None` for a grid of points +/// or handles that aren't finite. +fn as_read(m: &GradientMesh) -> Option<(u32, u32, Vec)> { + let (rows, cols) = (m.rows as usize, m.cols as usize); + let finite = |v: Vec2| v.x.is_finite() && v.y.is_finite(); + let mut pts = Vec::with_capacity(m.points.len()); + for (i, q) in m.points.iter().enumerate() { + if !finite(q.p.to_vec2()) || !q.handles.iter().all(|h| finite(*h)) { + return None; + } + let (r, c) = (i / (cols + 1), i % (cols + 1)); + let mut h = q.handles; + for (slot, out) in [(H_LEFT, c == 0), (H_RIGHT, c == cols), (H_UP, r == 0), (H_DOWN, r == rows)] { + if out && let Some(h) = h.get_mut(slot) { + *h = Vec2::ZERO; + } + } + pts.push((q.p, h)); + } + Some((m.rows, m.cols, pts)) +} + +/// Does the reader lay out grid `pts` (`rows`×`cols` patches) as it is written? +fn reads_as_written(rows: usize, cols: usize, pts: &[GridPoint]) -> bool { + let points = + pts.iter().map(|(p, handles)| MeshPoint { p: *p, color: vectorcraft_color::Color::BLACK, opacity: 1.0, handles: *handles }).collect(); + let mesh = GradientMesh { rows: rows as u32, cols: cols as u32, points }; + envelope_upright(&mesh).is_some_and(|(r, c, read)| (r as usize, c as usize) == (rows, cols) && read == pts) +} + +/// The grid a top object with outline `path` and corners `corners` is written with: its edge runs +/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, so +/// the top and bottom sides need as many segments as each other, as do the left and right sides; +/// the points inside blend the sides. `None` where that grid's edge, read back as the top object's +/// outline, doesn't give the same patch ([`Coons::from_top_object`]) at every place checked. +fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, usize, Vec)> { + let coons = Coons::from_top_object(path, corners)?; + // The outline, as the patch takes it: the largest subpath. + let mut sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?.clone(); + let count = sp.anchors.len(); + let empty = |i: usize| { + let c = sp.segment(i); + [c.p1, c.p2, c.p3].iter().all(|p| p.distance(c.p0) <= 1e-9) + }; + if !sp.closed || count < 4 || (0..count).any(empty) { + return None; + } + let fits = |c: &[usize; 4]| { + c.iter().all(|i| *i < count) && { + let gaps = (0..4).map(|k| (c[(k + 1) % 4] + count - c[k]) % count); + gaps.clone().all(|g| g > 0) && gaps.sum::() == count + } + }; + // Stored corners that fit keep the outline as drawn; otherwise it runs clockwise, and the + // corners are the anchors the patch's corners lie on. + let at = match corners.map(|c| c.map(|i| i as usize)).filter(fits) { + Some(c) => c, + None => { + if sp.signed_area() < 0.0 { + sp.reverse(); + } + let find = |q: Point| sp.anchors.iter().position(|a| a.p.distance(q) <= 1e-9); + let [tl, tr, br, bl] = coons.corners(); + let c = [find(tl)?, find(tr)?, find(br)?, find(bl)?]; + if !fits(&c) { + return None; + } + c + } + }; + let gap = |k: usize| (at[(k + 1) % 4] + count - at[k]) % count; + let (cols, rows) = (gap(0), gap(1)); + if gap(2) != cols || gap(3) != rows || cols > MAX_GRID || rows > MAX_GRID { + return None; + } + let width = cols + 1; + let mut pts: Vec = vec![(Point::ZERO, [Vec2::ZERO; 4]); (rows + 1) * width]; + // Where the `k`th anchor from the top left corner goes, and the slots of the handles of the + // segment from it to the next anchor: its own, and the next anchor's back. + let place = |k: usize| -> (usize, usize, usize, usize) { + if k < cols { + (0, k, H_RIGHT, H_LEFT) + } else if k < cols + rows { + (k - cols, cols, H_DOWN, H_UP) + } else if k < 2 * cols + rows { + (rows, cols - (k - cols - rows), H_LEFT, H_RIGHT) + } else { + (rows - (k - 2 * cols - rows), 0, H_UP, H_DOWN) + } + }; + for k in 0..count { + let i = (at[0] + k) % count; + let seg = sp.segment(i); + // A straight segment's patch spreads evenly along it, as a cubic's with its handles a third + // of the way does. + let (p1, p2) = if sp.segment_is_line(i) { (seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0)) } else { (seg.p1, seg.p2) }; + let (r, c, out, into) = place(k); + let (nr, nc, _, _) = place((k + 1) % count); + if let Some(q) = pts.get_mut(r * width + c) { + q.0 = seg.p0; + q.1[out] = p1 - seg.p0; + } + if let Some(q) = pts.get_mut(nr * width + nc) { + q.1[into] = p2 - seg.p3; + } + } + // The points inside, blended from the sides. + let edge = |r: usize, c: usize| pts.get(r * width + c).map_or(Point::ZERO, |q| q.0).to_vec2(); + let mut inside = vec![]; + for r in 1..rows { + for c in 1..cols { + let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64); + let p = (1.0 - v) * edge(0, c) + v * edge(rows, c) + (1.0 - u) * edge(r, 0) + u * edge(r, cols) + - ((1.0 - u) * (1.0 - v) * edge(0, 0) + u * (1.0 - v) * edge(0, cols) + (1.0 - u) * v * edge(rows, 0) + u * v * edge(rows, cols)); + inside.push((r * width + c, p.to_point())); + } + } + for (i, p) in inside { + if let Some(q) = pts.get_mut(i) { + q.0 = p; + } + } + if !pts.iter().all(|(p, h)| p.is_finite() && h.iter().all(|h| h.is_finite())) || !reads_as_written(rows, cols, &pts) { + return None; + } + // The top object the reader makes of the grid: its edge, with the corners of the grid. + let outline = envelope_outline(rows as u32, cols as u32, &pts)?; + let corners = [0, cols, cols + rows, 2 * cols + rows].map(|c| c as u32); + let read = Coons::from_top_object(&outline, Some(corners))?; + let [tl, tr, br, bl] = coons.corners(); + let reach = tl.distance(br).max(tr.distance(bl)).max(1.0) * TOP_OBJECT_TOLERANCE; + const STEPS: usize = 8; + let at = |i: usize| i as f64 / STEPS as f64; + let same = (0..=STEPS).all(|j| (0..=STEPS).all(|i| coons.eval(at(i), at(j)).distance(read.eval(at(i), at(j))) <= reach)); + same.then_some((rows, cols, pts)) +} + +impl Writer<'_> { + /// Envelope `n`, of transparency `xy`, as a live envelope (see the module's form), outside opacity + /// masks' art, pattern tiles, art cut to a clipping object and the layers of a large canvas. False, + /// writing nothing, where it is written as its art instead: where the reader would open it in + /// another shape, or some of it is left out for the object limit ([`Self::take_objects`]). + pub(super) fn envelope(&mut self, n: &Node, xy: Xy) -> bool { + let NodeKind::Envelope { content, kind, fidelity, editing, options, frame } = &n.kind else { return false }; + if self.hidden > 0 || self.clip.is_some() || self.nest >= MAX_NEST || !self.symbols_live || self.envelopes_left == 0 { + return false; + } + let Some(record) = record(n, content, kind, *fidelity, *editing, options, **frame) else { return false }; + let start = self.body.len(); + let (state, gs, charges, bound) = (self.state, self.gs, self.charges(), self.bound.len()); + self.nest += 1; + self.hidden += 1; + // The group before the dictionary: the grid, or the contents while they are edited. + let outer = std::mem::take(&mut self.body); + let opened = self.open_group("u"); + if opened { + if *editing { + self.contents(content); + } else { + self.grid(record.grid.as_ref()); + } + self.line("U"); + } + let before = std::mem::replace(&mut self.body, outer); + hide(&before, &mut self.body); + self.body.push_str("%_/ArtDictionary :\n"); + hide(&record.keys, &mut self.body); + self.body.push_str("%_X=\n"); + // The dictionary's art starts unlocked, shown and opaque, as an opacity mask's does. + let outer = std::mem::take(&mut self.body); + let at_block = self.state; + (self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE)); + if *editing { + self.grid(record.grid.as_ref()); + } else if self.open_group("u") { + self.contents(content); + self.end_group("U", Xy::PLAIN); + } + let block = std::mem::replace(&mut self.body, outer); + hide(&block, &mut self.body); + (self.state, self.gs, self.block_end) = (at_block, None, usize::MAX); + let _ = writeln!(self.body, "%_X+\n%_ (AIDeformArtKey) ,\n%_{} /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW", record.style); + self.hidden -= 1; + self.nest -= 1; + // The distorted art, which takes the envelope's lock, visibility, transparency, name and + // opacity mask. + self.state(n.locked, !n.visible); + self.drawn_art(n, xy, None); + if !opened || self.left_out_since(charges) { + let lines = self.body.split_off(start); + self.give_back(charges, &lines); + self.bound.truncate(bound); + (self.state, self.gs, self.block_end) = (state, gs, usize::MAX); + return false; + } + self.deform = true; + self.envelopes_left -= 1; + true + } + + /// An envelope's contents. + fn contents(&mut self, content: &[Arc]) { + for c in content { + self.node(c); + } + } + + /// An envelope's grid, when it has one, as a gray gradient mesh (`/Mesh X!` … `/End X!`): its + /// size (`[cols rows] /Size`), then each patch's place (`[col row] /P`), its four corners + /// (`/N`: gray, opacity, the point, the handle towards the next corner and a weight, the handle + /// towards the previous one and a weight, in art space), from its top left one down, across and + /// up, and `/E`. One object. + fn grid(&mut self, grid: Option<&(usize, usize, Vec)>) { + let Some((rows, cols, pts)) = grid else { return }; + if !self.take_objects(1) { + return; + } + let (rows, cols, width) = (*rows, *cols, cols + 1); + let to_art = self.to_art; + let s = &mut self.body; + let _ = writeln!(s, "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[{cols} {rows}] /Size X#\n/Data X#\n/DeviceGray /CS X#"); + let num = |s: &mut String, v: f64| { + s.push(' '); + ps::push_num(s, v); + }; + for c in 0..cols { + for r in 0..rows { + let _ = writeln!(s, "[{c} {r}] /P X#"); + // Each corner with the slots of its handles towards the next corner and the previous one. + let corners = [((r, c), H_DOWN, H_RIGHT), ((r + 1, c), H_RIGHT, H_UP), ((r + 1, c + 1), H_UP, H_LEFT), ((r, c + 1), H_LEFT, H_DOWN)]; + for ((pr, pc), next, prev) in corners { + let (p, h) = pts.get(pr * width + pc).copied().unwrap_or((Point::ZERO, [Vec2::ZERO; 4])); + let at = |v: Vec2| to_art * (p + v); + s.push_str("[0 1"); + for q in [at(Vec2::ZERO), at(h[next])] { + num(s, q.x); + num(s, q.y); + } + s.push_str(" 1"); + let q = at(h[prev]); + num(s, q.x); + num(s, q.y); + s.push_str(" 1] /N X#\n"); + } + s.push_str("/E X#\n"); + } + } + s.push_str("/End X!\n"); + } +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 2cc4bed521..bdb3b9cf50 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -82,12 +82,15 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; +#[cfg(test)] +pub(crate) use ai::ENVELOPE_DRAWN; pub use ai::NameEncoding; #[cfg(test)] pub(crate) use ai::objects_read; pub(crate) use ai::{ - BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, - read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, + BRUSH_MANAGER, BrushesRead, DEFORM_PLUGIN, EnvelopeGridPoint, MAX_CHECKED as ENVELOPE_CHECKS, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, + MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, envelope_outline, envelope_upright, read_art_style, read_brush_object, + read_style_list, style_moves_with, symbol_art_member, warp_number, }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 2d6c4a533d..a3de7560e3 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -36,9 +36,9 @@ use crate::ps; pub(crate) use brush::{BrushesRead, MANAGER_NAME as BRUSH_MANAGER, brushes_kept, plugin_name as brush_plugin_name, read_brush_object}; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; -#[cfg(test)] pub(crate) use envelope::MAX_CHECKED; pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN; +pub(crate) use envelope::{GridPoint as EnvelopeGridPoint, PLUGIN as DEFORM_PLUGIN, outline as envelope_outline, upright as envelope_upright}; pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub use obj::{NameEncoding, Names}; diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index edb9bba3e3..741ebfabdf 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -41,7 +41,7 @@ use super::obj::Obj; use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compare_scale, differ, differ_in_colour, differ_on_black}; /// The plugin that writes envelopes. -pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use +pub(crate) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use /// The note for an envelope that keeps its drawn art. pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art"; /// The note for an envelope of type, which keeps its drawn art with its type hidden (unless the @@ -366,12 +366,12 @@ fn only_mesh(n: &Node) -> Option<&GradientMesh> { } /// A grid point and its handles. -type GridPoint = (Point, [Vec2; 4]); +pub(crate) type GridPoint = (Point, [Vec2; 4]); /// The points of mesh `m` as a `rows`×`cols` grid with its first row at the top and its first /// column at the left (y down), as an envelope's mesh lays them out; `None` for a grid out of range /// or with points that aren't finite. -fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { +pub(crate) fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { let (mut rows, mut cols) = (m.rows, m.cols); if rows == 0 || cols == 0 || rows > MAX_GRID || cols > MAX_GRID { return None; @@ -451,7 +451,7 @@ fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { } /// The outline of an upright grid: its edge, clockwise from the top left corner. -fn outline(rows: u32, cols: u32, pts: &[GridPoint]) -> Option { +pub(crate) fn outline(rows: u32, cols: u32, pts: &[GridPoint]) -> Option { let at = |r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).copied(); // Each edge: from a point by one of its handles to the next point by one of its handles. type Edge = ((u32, u32), usize, (u32, u32), usize); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 3c0ac39a05..9b86f38350 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7730,6 +7730,55 @@ fn a_top_object_envelope_opens_live_with_its_outline() { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } +/// A top object whose saved grid has three patches across, its top edge one curve split into +/// thirds, maps its contents along that one curve. In this synthetic file the drawn art is drawn by +/// VectorCraft's own map of the unsplit curve, so the envelope opens live only when the split side +/// maps the same way, and the contents' first quarter ends a quarter of the way along the curve. +#[test] +fn a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live() { + use kurbo::{CubicBez, ParamCurve}; + use vectorcraft_doc::live::EnvelopeKind; + // In the document: the top runs from (40, 20) to (160, 20), its parameter fast at the start. + let top = CubicBez::new((40.0, 20.0), (112.0, -16.0), (154.0, 2.0), (160.0, 20.0)); + let thirds = [top.subsegment(0.0..1.0 / 3.0), top.subsegment(1.0 / 3.0..2.0 / 3.0), top.subsegment(2.0 / 3.0..1.0)]; + // Art space has y up: row 1 is the top. The bottom row and the columns are straight. + let up = |p: Point| (p.x, 100.0 - p.y); + let at = move |(i, j): (usize, usize)| { + if j == 1 { up(if i == 0 { top.p0 } else { thirds[i - 1].p3 }) } else { (40.0 + 40.0 * i as f64, 20.0) } + }; + let handle = move |p: (usize, usize), q: (usize, usize)| match (p, q) { + ((i, 1), (k, 1)) if k == i + 1 => up(thirds[i].p1), + ((i, 1), (k, 1)) if k + 1 == i => up(thirds[k].p2), + _ => { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + } + }; + let shape = mesh_text(3, 1, &at, &handle, None); + // The contents: two red bars at the box's left and right ends, drawn by the unsplit curve. + let mut bp = kurbo::BezPath::new(); + bp.move_to(top.p0); + bp.curve_to(top.p1, top.p2, top.p3); + bp.line_to((160.0, 80.0)); + bp.line_to((40.0, 80.0)); + bp.close_path(); + let one_curve = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp), corners: Some([0, 1, 2, 3]) }; + let src = Rect::new(40.0, 20.0, 160.0, 80.0); + let bars = [Rect::new(40.0, 20.0, 70.0, 80.0), Rect::new(130.0, 20.0, 160.0, 80.0)]; + let content: String = bars.iter().map(|b| format!("{RED}{}f\n", rect_path(b.x0, b.y0, b.x1, b.y1))).collect(); + let drawn: String = bars.iter().map(|b| drawn_box(&one_curve, src, *b)).collect(); + let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &content, 3, &drawn))); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (_, kind, ..) = live_envelope(&a[0]); + let EnvelopeKind::TopObject { path, .. } = kind else { panic!("{kind:?}") }; + assert_eq!(path.anchor_count(), 8, "{path:?}"); + let map = vectorcraft_doc::live::envelope_mapper(kind, src); + let (p, want) = (map(Point::new(70.0, 20.0)), top.eval(0.25)); + assert!((p - want).hypot() < 0.02, "{p:?}, want {want:?}"); +} + /// Envelope Options come from the options word, Fidelity from its own key. #[test] fn an_envelope_reads_its_options_and_fidelity() { diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index e8ffa4b278..0ec561dd31 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -55,8 +55,99 @@ const MAX_DEVICE_N: usize = 32; const MISSING_PATTERN: &str = "missing patterns are written as mid-grey"; const TOO_LARGE: &str = "the artwork is too large for EPS: what follows 256 MB of PostScript was left out"; /// The most PostScript the page body may grow to: symbols and patterns that hold one another -/// expand into every instance, so nesting depth alone doesn't bound the output. +/// expand into every instance, so nesting depth alone doesn't bound the output. An image placed +/// more than once counts its data once. const MAX_BODY: usize = 256 << 20; +/// Most compressed bytes in one string of an image's samples at Level 2 (under PostScript's +/// 65,535-byte string limit). +const IMAGE_CHUNK: usize = 48 << 10; +/// The start of the procedure that reads an image's data from the lines after it. +const INLINE_SOURCE: &str = "{ /VCsrc currentfile /ASCII85Decode filter def\n"; + +/// An image as written: what sets up its color, its image dictionary and its compressed samples. +/// Placed once, it is written where it is drawn, its data following the operators that read it. +/// Placed more than once, its samples are written once in the setup as `VCim` (a reusable +/// stream at Level 3, an array of strings at Level 2) and each placement reads them from there. +struct ImageCode { + /// The color space or ink operators, each line ending in a line break. + prelude: String, + /// `<< /ImageType … /ImageMatrix […]` with its mask colors: the dictionary up to its data source. + dict: String, + /// The decoding filter of the samples: `/FlateDecode` or `/RunLengthDecode`. + filter: &'static str, + /// `image` or `imagemask`. + op: &'static str, + /// The compressed samples in ASCII85, as lines with the end marker. + ascii: String, + /// The compressed samples, kept at Level 2, where a reused image's samples are written as + /// strings of at most [`IMAGE_CHUNK`] bytes. + data: Vec, + /// How many placements in the page draw it. + uses: usize, + /// The length of [`ImageCode::push_inline`]'s text. + inline_len: usize, + /// The length of [`ImageCode::reference`]'s text. + ref_len: usize, +} + +impl ImageCode { + /// The operators and data that draw the image where it is placed. + fn push_inline(&self, s: &mut String) { + s.push_str(&self.prelude); + s.push_str(INLINE_SOURCE); + s.push_str(&self.dict); + s.push_str(&self.inline_read()); + s.push_str(&self.ascii); + } + + /// The end of the procedure [`ImageCode::push_inline`] writes. The procedure is read whole + /// before it runs: the image data follows it, and flushing the ASCII85 filter afterwards reads + /// past its end marker. + fn inline_read(&self) -> String { + format!("\n/DataSource VCsrc {} filter >> {}\nVCsrc flushfile }} exec\n", self.filter, self.op) + } + + /// The operators that draw the image `VCim` holds, for an image placed more than once. + fn reference(&self, i: usize, level: Level) -> String { + match level { + // The stream is read again from its start. + Level::Three => { + format!("{}VCim{i} resetfile\n{}\n/DataSource VCim{i} {} filter >> {}\n", self.prelude, self.dict, self.filter, self.op) + } + // A procedure gives the strings in turn, then an empty string: the end of the data. + Level::Two => format!( + "{}/VCi 0 def\n{}\n/DataSource {{VCi VCim{i} length lt {{VCim{i} VCi get /VCi VCi 1 add def}} {{()}} ifelse}} {} filter >> {}\n", + self.prelude, self.dict, self.filter, self.op + ), + } + } + + /// The setup's definition of `VCim`: the image's compressed samples, once. + fn resource(&self, i: usize, level: Level) -> String { + let mut s = String::with_capacity(self.ascii.len() + 200); + match level { + Level::Three => { + // The stream reads all of its data when it is made, so the data follows the + // procedure that makes it. + let intent = if self.op == "imagemask" { 1 } else { 0 }; + let _ = writeln!( + s, + "{{ /VCsrc currentfile /ASCII85Decode filter def\n/VCim{i} VCsrc << /Intent {intent} /AsyncRead false /CloseSource false >> /ReusableStreamDecode filter def\nVCsrc flushfile }} exec" + ); + s.push_str(&self.ascii); + } + Level::Two => { + let _ = writeln!(s, "/VCim{i} ["); + for chunk in self.data.chunks(IMAGE_CHUNK) { + s.push_str("<~"); + s.push_str(&ps::ascii85(chunk)); + } + s.push_str("] def\n"); + } + } + s + } +} /// What the walk wrote: definitions for the setup, the page, the spot colours it uses (name and /// CMYK equivalent) and the warnings. @@ -157,10 +248,18 @@ pub(crate) struct Scene<'a> { device_n: Vec, warnings: Vec, brushes: Option>, - /// Image keys → what draws them (`None`: left out). - images: HashMap>>, + /// Image keys → the index in `codes` of what draws them (`None`: left out). + images: HashMap>, /// The source key and solid paint together identify a colorized encoding. - colorized_images: HashMap<(String, String), Option>>, + colorized_images: HashMap<(String, String), Option>, + /// The encoded images. + codes: Vec, + /// Where in `body` each placement of an encoded image goes (in order) and the index of its code. + placements: Vec<(usize, usize)>, + /// What the placements in `placements` add to `body`'s length when the page is finished. + pending: usize, + /// The most the page body may grow to ([`MAX_BODY`]). + max_body: usize, /// Symbols and brush art being written inside one another. nest: u32, } @@ -181,10 +280,21 @@ impl<'a> Scene<'a> { brushes: None, images: HashMap::new(), colorized_images: HashMap::new(), + codes: vec![], + placements: vec![], + pending: 0, + max_body: MAX_BODY, nest: 0, } } + /// This scene with a page body of at most `max` bytes. + #[cfg(test)] + pub fn with_max_body(mut self, max: usize) -> Self { + self.max_body = max; + self + } + pub fn run(mut self) -> Page { let doc = self.doc; for l in &doc.layers { @@ -199,7 +309,27 @@ impl<'a> Scene<'a> { self.page() } - fn page(self) -> Page { + fn page(mut self) -> Page { + if !self.placements.is_empty() { + let level = self.o.level; + let mut body = String::with_capacity(self.body.len().saturating_add(self.pending)); + let mut at = 0; + for &(offset, i) in &self.placements { + body.push_str(self.body.get(at..offset).unwrap_or_default()); + at = offset; + let Some(code) = self.codes.get(i) else { continue }; + if code.uses > 1 { + body.push_str(&code.reference(i, level)); + } else { + code.push_inline(&mut body); + } + } + body.push_str(self.body.get(at..).unwrap_or_default()); + self.body = body; + for (i, code) in self.codes.iter().enumerate().filter(|(_, c)| c.uses > 1) { + self.setup.push_str(&code.resource(i, level)); + } + } let custom = self .spots .iter() @@ -337,7 +467,7 @@ impl<'a> Scene<'a> { if let NodeKind::Layer { template: true, .. } = n.kind { return; } - if self.body.len() >= MAX_BODY { + if self.size() >= self.max_body { self.warn(TOO_LARGE); return; } @@ -347,7 +477,7 @@ impl<'a> Scene<'a> { _ => {} } self.note_losses(n); - let was = self.body.len(); + let (was, placed) = (self.body.len(), self.placements.len()); match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { children.iter().for_each(|c| self.node(c, false)); @@ -402,12 +532,29 @@ impl<'a> Scene<'a> { // Ruler guides draw on screen only. NodeKind::RulerGuide { .. } => {} } - if self.body.len() > MAX_BODY { + if self.size() > self.max_body { self.body.truncate(was); + self.unplace(placed); self.warn(TOO_LARGE); } } + /// How long the page body will be: what is written and what its image placements add. + fn size(&self) -> usize { + self.body.len().saturating_add(self.pending) + } + + /// Take back the image placements after the first `n`, last first. + fn unplace(&mut self, n: usize) { + while self.placements.len() > n { + let Some((_, i)) = self.placements.pop() else { break }; + let Some(code) = self.codes.get_mut(i) else { continue }; + code.uses = code.uses.saturating_sub(1); + let cost = if code.uses == 0 { code.inline_len } else { code.ref_len }; + self.pending = self.pending.saturating_sub(cost); + } + } + /// A clipping group: the art inside its first child's outline. The clipping path's fills /// paint behind the art (clipped) and its strokes over it (not clipped). fn clip_group(&mut self, children: &[Arc]) { @@ -1229,7 +1376,7 @@ impl<'a> Scene<'a> { }; let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height()); if let Some(image) = image { - self.place_image(&image, place); + self.place_inline(&image, place); } } @@ -1241,18 +1388,29 @@ impl<'a> Scene<'a> { return; } let encoded = match self.images.get(key) { - Some(e) => e.clone(), + Some(e) => *e, None => { - let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(Arc::new); - self.images.insert(key.to_string(), e.clone()); + let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(|c| self.add_code(c)); + self.images.insert(key.to_string(), e); e } }; - if let Some(e) = encoded { - self.place_image(&e, place); + if let Some(i) = encoded { + self.place_image(i, place); } } + /// Keep `code` for the placements that draw it: its index in `codes`. + fn add_code(&mut self, mut code: ImageCode) -> usize { + let i = self.codes.len(); + if self.o.level == Level::Three { + code.data = vec![]; + } + code.ref_len = code.reference(i, self.o.level).len(); + self.codes.push(code); + i + } + /// Gray coverage in the selected process or Separation ink. Binary coverage is a stencil; /// continuous coverage leaves zero pixels untouched at Level 3 and writes them as paper at Level 2. fn colorized_image(&mut self, key: &str, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, paint: &Paint) { @@ -1262,19 +1420,19 @@ impl<'a> Scene<'a> { } let cache = (key.to_string(), format!("{paint:?}")); let encoded = match self.colorized_images.get(&cache) { - Some(e) => e.clone(), + Some(e) => *e, None => { - let e = self.encode_colorized(blob, paint).map(Arc::new); - self.colorized_images.insert(cache, e.clone()); + let e = self.encode_colorized(blob, paint).map(|c| self.add_code(c)); + self.colorized_images.insert(cache, e); e } }; - if let Some(e) = encoded { - self.place_image(&e, place); + if let Some(i) = encoded { + self.place_image(i, place); } } - fn encode_colorized(&mut self, blob: Option<&ImageBlob>, paint: &Paint) -> Option { + fn encode_colorized(&mut self, blob: Option<&ImageBlob>, paint: &Paint) -> Option { let Some((w, h, coverage)) = blob.and_then(ImageBlob::coverage) else { self.warn(UNDECODED); return None; @@ -1355,17 +1513,17 @@ impl<'a> Scene<'a> { ("/RunLengthDecode", rl) } }; - let mut out = String::new(); + let mut prelude = String::new(); if binary { // imagemask takes its current ink from the graphics state. - out.push_str(&self.ink_code(ink)); + prelude.push_str(&self.ink_code(ink)); } else { match ink { Ink::Spot { space, .. } => { - let _ = writeln!(out, "VCsp{space} setcolorspace"); + let _ = writeln!(prelude, "VCsp{space} setcolorspace"); } Ink::Process(space, ..) => { - let _ = writeln!(out, "{} setcolorspace", space.name()); + let _ = writeln!(prelude, "{} setcolorspace", space.name()); } } } @@ -1374,19 +1532,16 @@ impl<'a> Scene<'a> { Ink::Process(space, ..) => space.n(), }; let decode = if binary { "1 0".to_string() } else { "0 1 ".repeat(n).trim_end().to_string() }; - let _ = write!( - out, - "{{ /VCsrc currentfile /ASCII85Decode filter def\n<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent {} /Decode [{decode}] /ImageMatrix [{w} 0 0 {h} 0 0]", + let mut dict = format!( + "<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent {} /Decode [{decode}] /ImageMatrix [{w} 0 0 {h} 0 0]", if keyed { 4 } else { 1 }, if binary { 1 } else { 8 }, ); if keyed { let mask: Vec = key.iter().take(n).map(|v| format!("{v} {v}")).collect(); - let _ = write!(out, " /MaskColor [{}]", mask.join(" ")); + let _ = write!(dict, " /MaskColor [{}]", mask.join(" ")); } - let _ = writeln!(out, "\n/DataSource VCsrc {filter} filter >> {}\nVCsrc flushfile }} exec", if binary { "imagemask" } else { "image" }); - out.push_str(&ps::ascii85(&encoded)); - Some(out) + Some(image_code(prelude, dict, filter, if binary { "imagemask" } else { "image" }, encoded)) } /// Ink-setting operators in the cached stencil body, built with the same path as solid fills. @@ -1409,18 +1564,40 @@ impl<'a> Scene<'a> { Some(img.to_rgba8()) } - /// Draw an image of [`Self::encode_image`] on the unit square mapped by `place`. - fn place_image(&mut self, encoded: &str, place: Affine) { + /// Draw the image of `codes[i]` on the unit square mapped by `place`. Its operators go in when + /// the page is finished ([`Self::page`]), once it is known how many placements draw it. + fn place_image(&mut self, i: usize, place: Affine) { if !invertible(place) { return; } - if self.body.len().saturating_add(encoded.len()).saturating_add(64) > MAX_BODY { + let Some(code) = self.codes.get(i) else { return }; + let cost = if code.uses == 0 { code.inline_len } else { code.ref_len }; + if self.size().saturating_add(cost).saturating_add(64) > self.max_body { self.warn(TOO_LARGE); return; } self.line("q"); let _ = writeln!(self.body, "{} cm", ps::matrix(place)); - self.body.push_str(encoded); + self.placements.push((self.body.len(), i)); + self.pending = self.pending.saturating_add(cost); + if let Some(code) = self.codes.get_mut(i) { + code.uses += 1; + } + self.line("Q"); + } + + /// Draw `code`, an image drawn nowhere else, on the unit square mapped by `place`. + fn place_inline(&mut self, code: &ImageCode, place: Affine) { + if !invertible(place) { + return; + } + if self.size().saturating_add(code.inline_len).saturating_add(64) > self.max_body { + self.warn(TOO_LARGE); + return; + } + self.line("q"); + let _ = writeln!(self.body, "{} cm", ps::matrix(place)); + code.push_inline(&mut self.body); self.line("Q"); } @@ -1428,7 +1605,7 @@ impl<'a> Scene<'a> { /// them on the unit square: in the process colour space, transparent pixels masked out at /// Level 3 (a key colour no opaque pixel has), white at Level 2; partly transparent ones over /// white. `None` for an empty image. - fn encode_image(&mut self, rgba: &[u8], w: u32, h: u32) -> Option { + fn encode_image(&mut self, rgba: &[u8], w: u32, h: u32) -> Option { let space = if self.cmyk { Space::Cmyk } else { Space::Rgb }; let mut cache: HashMap<[u8; 3], [u8; 4]> = HashMap::new(); let cms = self.cms.clone(); @@ -1447,7 +1624,7 @@ impl<'a> Scene<'a> { /// Straight CMYK pixels with opacity (five bytes a pixel, `w` × `h`, rows from the top), drawn /// as [`Self::encode_image`] draws its pixels and keeping their ink amounts: partly /// transparent pixels lay down that share of each ink. - fn encode_inks(&mut self, cmyka: &[u8], w: u32, h: u32) -> Option { + fn encode_inks(&mut self, cmyka: &[u8], w: u32, h: u32) -> Option { self.encode_samples(cmyka.as_chunks::<5>().0, w, h, Space::Cmyk, |p: &[u8; 5], al| { [0, 1, 2, 3].map(|i| ((u32::from(p[i]) * al + 127) / 255) as u8) }) @@ -1463,7 +1640,7 @@ impl<'a> Scene<'a> { h: u32, space: Space, mut sample: impl FnMut(&[u8; N], u32) -> [u8; 4], - ) -> Option { + ) -> Option { if w == 0 || h == 0 { return None; } @@ -1503,27 +1680,29 @@ impl<'a> Scene<'a> { ("/RunLengthDecode", rl) } }; - let mut out = String::with_capacity(encoded.len() * 5 / 4 + 400); - let _ = writeln!(out, "{} setcolorspace", space.name()); + let prelude = format!("{} setcolorspace\n", space.name()); let decode = "0 1 ".repeat(n); - let _ = write!( - out, - "{{ /VCsrc currentfile /ASCII85Decode filter def\n<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent 8 /Decode [{}] /ImageMatrix [{w} 0 0 {h} 0 0]", + let mut dict = format!( + "<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent 8 /Decode [{}] /ImageMatrix [{w} 0 0 {h} 0 0]", if keyed { 4 } else { 1 }, decode.trim_end() ); if keyed { let mask: Vec = key.iter().take(n).map(|v| format!("{v} {v}")).collect(); - let _ = write!(out, " /MaskColor [{}]", mask.join(" ")); + let _ = write!(dict, " /MaskColor [{}]", mask.join(" ")); } - // The procedure is read whole before it runs: the image data follows it, and flushing the - // ASCII85 filter afterwards reads past its end marker. - let _ = writeln!(out, "\n/DataSource VCsrc {filter} filter >> image\nVCsrc flushfile }} exec"); - out.push_str(&ps::ascii85(&encoded)); - Some(out) + Some(image_code(prelude, dict, filter, "image", encoded)) } } +/// An image of `prelude`, `dict`, `filter` and `op` (see [`ImageCode`]) with compressed samples +/// `data`, not yet placed. +fn image_code(prelude: String, dict: String, filter: &'static str, op: &'static str, data: Vec) -> ImageCode { + let mut code = ImageCode { prelude, dict, filter, op, ascii: ps::ascii85(&data), data, uses: 0, inline_len: 0, ref_len: 0 }; + code.inline_len = code.prelude.len() + INLINE_SOURCE.len() + code.dict.len() + code.inline_read().len() + code.ascii.len(); + code +} + /// `a` to `b` at `t`. fn mix(a: &[f32], b: &[f32], t: f32) -> Vec { a.iter().zip(b).map(|(x, y)| x + (y - x) * t).collect() diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 13bdbb80e2..0d5649ead5 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -82,6 +82,17 @@ fn checked_page(ps: &str) -> String { } } '{' | '[' => stack.push(c), + // An ASCII85 string: its digits are data. + '<' if chars.peek() == Some(&'~') => { + let mut last = ' '; + loop { + let c = chars.next().expect("ASCII85 string closes"); + if last == '~' && c == '>' { + break; + } + last = c; + } + } '<' if chars.peek() == Some(&'<') => { chars.next(); stack.push('<'); @@ -679,15 +690,20 @@ fn png_doc() -> Document { for (x, _, p) in img.enumerate_pixels_mut() { *p = if x < 2 { image::Rgba([0, 200, 0, 255]) } else { image::Rgba([0, 0, 0, 0]) }; } + image_doc(&img, 10.0) +} + +/// A 200 × 200 pt document holding `img` as a PNG, its pixels `scale` points wide, at (10, 10). +fn image_doc(img: &image::RgbaImage, scale: f64) -> Document { let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); let mut d = Document::new(200.0, 200.0); d.images.insert("pic".into(), ImageBlob::new("image/png", png)); let im = ImageObject { key: "pic".into(), - width: 4, - height: 2, - xf: Affine::translate((10.0, 10.0)) * Affine::scale(10.0), + width: img.width(), + height: img.height(), + xf: Affine::translate((10.0, 10.0)) * Affine::scale(scale), link: None, placement: Default::default(), pixels: None, @@ -988,6 +1004,219 @@ fn uncolorized_image_stays_on_original_eps_path() { assert_eq!(page_of(&color, &opts(&color)), original); } +/// A copy of the first layer's object `i`, moved by `xf`, added on top. +fn place_again(d: &mut Document, i: usize, xf: Affine) { + let mut n = (*d.layers[0].children().unwrap()[i]).clone(); + n.id = d.alloc_id(); + if let NodeKind::Image(im) = &mut n.kind { + im.xf = xf * im.xf; + } + d.insert(d.default_layer(), usize::MAX, n).unwrap(); +} + +/// Pixels of noise, opaque. +fn noise(w: u32, h: u32) -> image::RgbaImage { + let mut seed = 1u32; + image::RgbaImage::from_fn(w, h, |_, _| { + let mut px = [255u8; 4]; + for v in &mut px[..3] { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + *v = (seed >> 16) as u8; + } + image::Rgba(px) + }) +} + +/// The pixels of each image an EPS file's page draws, read back from the page. +fn imported_images(bytes: &[u8]) -> Vec> { + fn walk(n: &Node, d: &Document, out: &mut Vec>) { + if let NodeKind::Image(im) = &n.kind { + out.push(image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8().into_raw()); + } + for c in n.children().into_iter().flatten() { + walk(c, d, out); + } + } + let r = import_with(bytes, false).unwrap(); + assert!(!r.preview, "{:?}", r.warnings); + let mut out = vec![]; + for l in &r.document.layers { + walk(l, &r.document, &mut out); + } + out +} + +/// The samples of an image placed more than once, from the setup that defines them: inflated at +/// Level 3, each string run-length decoded in turn at Level 2. +fn reused_samples(ps: &str, level: Level) -> Vec { + let setup = &ps[ps.find("%%BeginSetup").unwrap()..ps.find("%%EndSetup").unwrap()]; + match level { + Level::Three => ps::inflate(&image_data(setup), 1 << 28).unwrap(), + Level::Two => { + let mut data = vec![]; + for s in setup.split("<~").skip(1) { + let chunk = ps::ascii85_decode(&s[..s.find("~>").unwrap() + 2]).unwrap(); + assert!(chunk.len() <= 65_535, "a string holds at most 65,535 bytes: {}", chunk.len()); + data.extend(chunk); + } + unpack(&data) + } + } +} + +/// The samples of a single image written where it is drawn. +fn inline_samples(ps: &str, level: Level) -> Vec { + match level { + Level::Three => ps::inflate(&image_data(ps), 1 << 28).unwrap(), + Level::Two => unpack(&image_data(ps)), + } +} + +/// How many placements draw the image the setup defines as `VCim`. +fn reuses(ps: &str, i: usize, level: Level) -> usize { + match level { + Level::Three => ps.matches(&format!("VCim{i} resetfile\n")).count(), + Level::Two => ps.matches(&format!("{{VCi VCim{i} length lt")).count(), + } +} + +#[test] +fn an_image_placed_twice_writes_its_samples_once_at_both_levels() { + let single = png_doc(); + let mut d = single.clone(); + place_again(&mut d, 0, Affine::translate((60.0, 80.0)) * Affine::rotate(0.5)); + for level in Level::ALL { + let one = export(&single, &EpsOptions { level, ..opts(&single) }, None, None).unwrap(); + let one_ps = text(&one); + assert!(!one_ps.contains("VCim"), "an image placed once is written where it is drawn: {one_ps}"); + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + checked_page(&ps); + assert_eq!(reuses(&ps, 0, level), 2, "{ps}"); + assert!(!ps.contains("VCim1"), "{ps}"); + let body = &ps[ps.find("%%EndSetup").unwrap()..]; + assert!(!body.contains("currentfile") && !body.contains("~>"), "no samples in the page: {ps}"); + match level { + Level::Three => { + assert_eq!(ps.matches("/ReusableStreamDecode filter def").count(), 1, "{ps}"); + assert!(ps.contains("/Intent 0 /AsyncRead false /CloseSource false"), "{ps}"); + assert!(body.contains("/DataSource VCim0 /FlateDecode filter >> image\n"), "{ps}"); + } + Level::Two => { + assert!(ps.contains("/VCim0 [\n<~") && !ps.contains("ReusableStreamDecode"), "{ps}"); + assert!(body.contains("/VCi 0 def\n") && body.contains("/RunLengthDecode filter >> image\n"), "{ps}"); + } + } + assert_eq!(reused_samples(&ps, level), inline_samples(&one_ps, level)); + assert_eq!(out.warnings, one.warnings); + // Read back, each placement draws the image the single placement draws. + let once = imported_images(&one.bytes); + assert_eq!(once.len(), 1); + assert_eq!(imported_images(&out.bytes), vec![once[0].clone(); 2], "{level:?}"); + } +} + +/// An image in an envelope is drawn as clipped pieces, all from one copy of its samples, which the +/// image placed beside the envelope also draws. +#[test] +fn an_envelopes_image_pieces_draw_one_copy_of_its_samples() { + let mut d = png_doc(); + let mut content = (*d.layers[0].children().unwrap()[0]).clone(); + content.id = d.alloc_id(); + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; + let envelope = Node::new( + d.alloc_id(), + NodeKind::Envelope { + content: vec![Arc::new(content)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }, + ); + d.insert(d.default_layer(), usize::MAX, envelope).unwrap(); + for level in Level::ALL { + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + checked_page(&ps); + // The plain image and 200 pieces: 10 × 10 cells of two triangles. + assert_eq!(reuses(&ps, 0, level), 201, "{ps}"); + assert!(!ps.contains("VCim1"), "{ps}"); + assert_eq!(ps.matches(if level == Level::Three { "/ReusableStreamDecode" } else { "<~" }).count(), 1, "{ps}"); + assert!(!out.warnings.iter().any(|w| w.contains("too large")), "{:?}", out.warnings); + } +} + +#[test] +fn each_ink_of_a_colorized_image_placed_twice_is_written_once() { + let first = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 }; + let mut d = colorized_doc(vec![0, 255, 0], Some(first)); + let mut n = (*d.layers[0].children().unwrap()[0]).clone(); + n.id = d.alloc_id(); + n.extras_mut().colorize = Some(Box::new(Paint::Solid { color: Color::rgb8(255, 128, 128), swatch: Some("Ink".into()), tint: 0.5 })); + d.insert(d.default_layer(), usize::MAX, n).unwrap(); + place_again(&mut d, 0, Affine::translate((0.0, 50.0))); + place_again(&mut d, 1, Affine::translate((0.0, 100.0))); + for level in Level::ALL { + let ps = page_of(&d, &EpsOptions { level, ..opts(&d) }); + assert_eq!((reuses(&ps, 0, level), reuses(&ps, 1, level)), (2, 2), "{ps}"); + assert_eq!(ps.matches("VCsp0 setcolorspace 1 setcolor\n").count(), 2, "{ps}"); + assert_eq!(ps.matches("VCsp0 setcolorspace 0.5 setcolor\n").count(), 2, "{ps}"); + assert_eq!(ps.matches("imagemask").count(), 4, "{ps}"); + if level == Level::Three { + assert_eq!(ps.matches("/Intent 1 ").count(), 2, "image masks: {ps}"); + } + } +} + +/// Samples longer than PostScript's longest string: one stream at Level 3, several strings at +/// Level 2, each placement reading all of them. +#[test] +fn samples_longer_than_a_string_are_reused_at_both_levels() { + let single = image_doc(&noise(160, 160), 1.0); + let mut d = single.clone(); + place_again(&mut d, 0, Affine::translate((20.0, 0.0))); + for level in Level::ALL { + let one = export(&single, &EpsOptions { level, ..opts(&single) }, None, None).unwrap(); + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + checked_page(&ps); + assert_eq!(reuses(&ps, 0, level), 2); + if level == Level::Two { + assert_eq!(ps.matches("<~").count(), 2, "160 × 160 × 3 run-length bytes take two strings"); + } + let samples = reused_samples(&ps, level); + assert_eq!(samples.len(), 160 * 160 * 3); + assert_eq!(samples, inline_samples(&text(&one), level)); + let once = imported_images(&one.bytes); + assert_eq!(imported_images(&out.bytes), vec![once[0].clone(); 2], "{level:?}"); + } +} + +/// The limit on the page body counts the samples of an image placed many times once. Art taken +/// back for passing the limit takes its image placements with it. +#[test] +fn the_page_limit_counts_a_repeated_images_samples_once() { + let single = image_doc(&noise(40, 40), 1.0); + let o = opts(&single); + let inline = crate::scene::Scene::new(&single, &o).run().body.len(); + let mut d = single.clone(); + for k in 1..20 { + place_again(&mut d, 0, Affine::translate((f64::from(k), 0.0))); + } + let page = crate::scene::Scene::new(&d, &o).with_max_body(inline * 3).run(); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); + assert_eq!(reuses(&page.body, 0, Level::Three), 20); + assert!(page.setup.contains("/VCim0 VCsrc")); + // Past the limit, the placements that don't fit are left out with a warning. + let page = crate::scene::Scene::new(&d, &o).with_max_body(inline + 1000).run(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings); + let placed = reuses(&page.body, 0, Level::Three); + assert!((2..20).contains(&placed), "{placed}"); + assert!(page.body.len() + page.setup.len() <= inline + 1000 + 400, "{}", page.body.len()); +} + #[test] fn run_length_and_ascii85_round_trip() { let cycle: Vec = (0..=255u8).cycle().take(1000).collect(); diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 59a8f30660..3c1b45a52b 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -688,7 +688,8 @@ fn box_gap(c: &CubicBez, p: Point) -> Option { const MEASURE_LIMIT: f64 = 1e9; /// The length of `seg` to `accuracy`; 0 for a segment whose control points are not finite or past -/// [`MEASURE_LIMIT`]. +/// [`MEASURE_LIMIT`], and 0 when the measurement is not finite (kurbo gives NaN for a quadratic +/// segment whose three points coincide). pub fn measured_arclen(seg: &PathSeg, accuracy: f64) -> f64 { let ok = |p: Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MEASURE_LIMIT && p.y.abs() <= MEASURE_LIMIT; let fits = match seg { @@ -696,7 +697,11 @@ pub fn measured_arclen(seg: &PathSeg, accuracy: f64) -> f64 { PathSeg::Quad(q) => ok(q.p0) && ok(q.p1) && ok(q.p2), PathSeg::Cubic(c) => ok(c.p0) && ok(c.p1) && ok(c.p2) && ok(c.p3), }; - if fits { kurbo::ParamCurveArclen::arclen(seg, accuracy) } else { 0.0 } + if !fits { + return 0.0; + } + let len = kurbo::ParamCurveArclen::arclen(seg, accuracy); + if len.is_finite() { len } else { 0.0 } } /// The largest x or y, in size, of a segment's control points for which [`segment_bounds`] @@ -1056,6 +1061,19 @@ mod tests { assert!((square().length() - 40.0).abs() < 1e-6); } + /// A quadratic segment whose three points coincide measures 0, not NaN; quadratic segments + /// with only two points together, and an ordinary one, keep kurbo's length. + #[test] + fn measured_arclen_of_a_quad_whose_points_coincide_is_zero() { + let quad = |a: (f64, f64), b: (f64, f64), c: (f64, f64)| PathSeg::Quad(kurbo::QuadBez::new(a, b, c)); + assert_eq!(measured_arclen(&quad((5.0, 5.0), (5.0, 5.0), (5.0, 5.0)), 1e-6), 0.0); + assert_eq!(measured_arclen(&quad((0.0, 0.0), (0.0, 0.0), (0.0, 0.0)), 1e-3), 0.0); + assert!((measured_arclen(&quad((0.0, 0.0), (0.0, 0.0), (10.0, 0.0)), 1e-6) - 10.0).abs() < 1e-6); + assert!((measured_arclen(&quad((0.0, 0.0), (10.0, 0.0), (0.0, 0.0)), 1e-6) - 10.0).abs() < 1e-6); + let ordinary = quad((0.0, 0.0), (10.0, 0.0), (10.0, 10.0)); + assert_eq!(measured_arclen(&ordinary, 1e-6), kurbo::ParamCurveArclen::arclen(&ordinary, 1e-6)); + } + #[test] fn serde_roundtrip() { let p = square(); diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index fbb5e4fc90..fb980843e8 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -74,6 +74,21 @@ pub enum HostError { /// A function the host gives a script, with its arguments converted. pub type HostFunction = Rc) -> Result>; +/// The place in the script's code that reads a [`HostItem::SiteGetter`] property: the source tag +/// of that code, as [`crate::Realm::error_tag`] gives one, and the line and column there, counted +/// from 1. A function an earlier script defined keeps its script's tag; code that `eval` or +/// `Function` compiled has [`crate::UNKNOWN_SOURCE`]. A read with none of the script's code +/// running has [`crate::UNKNOWN_SOURCE`] and line and column 0. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CallSite { + pub source: u32, + pub line: u32, + pub column: u32, +} + +/// A getter the host gives a script that is told where the code reading it is. +pub type SiteFunction = Rc Result>; + /// How one argument of a [`HostItem::Converted`] function is read. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Arg { @@ -96,6 +111,8 @@ pub(crate) enum ArgRead { Text, /// One by one as the list says; those past its end are not read. Each(Rc<[Arg]>), + /// None: the function is given the [`CallSite`] of the code that calls it, as three numbers. + Site, } /// A member of an object the host adds to the global environment. @@ -114,6 +131,9 @@ pub enum HostItem { /// A property whose value the function gives each time it is read, frozen as /// [`HostItem::Value`] gives its value. FrozenGetter(HostFunction), + /// A property whose value the function gives each time it is read, given the [`CallSite`] of + /// the code that reads it. + SiteGetter(SiteFunction), /// A property whose value `get` gives each time it is read, and that `set` is given each value /// assigned to it, as its one argument; an error from `set` is thrown to the script. Property { @@ -357,7 +377,10 @@ impl Interp { /// Calls host function `f`: reads its arguments as `how` says, runs it, and gives its result to /// the script. pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value], how: &ArgRead) -> JsResult { - let data = self.read_args(name, args, how)?; + let data = match how { + ArgRead::Site => site_data(self.call_site()), + _ => self.read_args(name, args, how)?, + }; // Only the script's own code, in a run that can pause, can wait for a result. let can_wait = self.in_slice && self.reentry == 0; self.can_wait.set(can_wait); @@ -375,6 +398,18 @@ impl Interp { } } + /// Where the innermost frame of the script's code is: its source's tag and the line and column + /// of the instruction it is running. + fn call_site(&self) -> CallSite { + match self.script_frames().last() { + Some((code, pc, _)) => { + let (line, column) = code.line_column(pc.saturating_sub(1)); + CallSite { source: code.source.tag, line, column } + } + None => CallSite { source: crate::UNKNOWN_SOURCE, line: 0, column: 0 }, + } + } + /// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the /// source it gives as global code, as an indirect `eval` does, giving that code's completion /// value. A result that is not a string is given to the script as it is. `end` is called once @@ -442,7 +477,7 @@ impl Interp { for (i, a) in args.iter().enumerate() { reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX))); let d = match how { - ArgRead::Data | ArgRead::Frozen => self.read_data(a, &mut reading), + ArgRead::Data | ArgRead::Frozen | ArgRead::Site => self.read_data(a, &mut reading), ArgRead::Text => self.read_text(a, &mut reading), ArgRead::Each(list) => match list.get(i) { Some(Arg::TextOrUndefined) if matches!(a, Value::Undefined) => Ok(Data::Undefined), @@ -531,6 +566,10 @@ impl Interp { HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE }, HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE }, HostItem::FrozenGetter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Frozen)), set: None, flags: ENUMERABLE }, + HostItem::SiteGetter(f) => { + let get: HostFunction = Rc::new(move |data| f(read_site(&data))); + Prop::Accessor { get: Some(self.host_function(get, &full, ArgRead::Site)), set: None, flags: ENUMERABLE } + } HostItem::Property { get, set } => Prop::Accessor { get: Some(self.host_function(get, &full, ArgRead::Data)), set: Some(self.host_function(set, &full, ArgRead::Data)), @@ -577,3 +616,18 @@ enum Slot { Index(usize), Name(String), } + +/// `site` as the three numbers [`ArgRead::Site`] gives its function. +fn site_data(site: CallSite) -> Vec { + vec![Data::Number(f64::from(site.source)), Data::Number(f64::from(site.line)), Data::Number(f64::from(site.column))] +} + +/// The [`CallSite`] that [`site_data`] gave as `data`. +fn read_site(data: &[Data]) -> CallSite { + let at = |i: usize, missing: u32| match data.get(i) { + // Each number is a `u32` that `site_data` gave, so the conversion is exact. + Some(Data::Number(n)) => *n as u32, + _ => missing, + }; + CallSite { source: at(0, crate::UNKNOWN_SOURCE), line: at(1, 0), column: at(2, 0) } +} diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index de252c6c64..700efddfb2 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -271,6 +271,41 @@ fn a_frozen_getter_gives_a_frozen_value_each_time() { assert_eq!(r.run(source).unwrap(), JsValue::String("true,true,true,1,false,false".into())); } +/// A site getter is told the source tag, line and column of the code that reads it: a function an +/// earlier script or an evaluator's source defined keeps that source's tag, and code that `eval` +/// compiled has none. +#[test] +fn a_site_getter_is_told_where_the_code_that_reads_it_is() { + let mut r = realm(10_000_000); + let seen: Rc>> = Rc::default(); + let log = seen.clone(); + let site: SiteFunction = Rc::new(move |s| { + log.borrow_mut().push(s); + Ok(Data::Number(f64::from(s.line))) + }); + let source: HostFunction = Rc::new(|_| Ok(Data::String("\n\nfunction inEval() { return host.site; }".into()))); + let load = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + r.define_host_object("host", vec![("site".into(), HostItem::SiteGetter(site)), ("load".into(), load)]).unwrap(); + r.run("function early() {\n return host.site;\n}\nhost.load();").unwrap(); + let first = r.script_tag(); + assert_eq!(r.run("\n\n\n\nhost.site + early() + inEval() + eval('host.site')").unwrap(), JsValue::Number(5.0 + 2.0 + 3.0 + 1.0)); + let second = r.script_tag(); + let sites = seen.borrow().clone(); + assert_eq!( + sites, + vec![ + CallSite { source: second, line: 5, column: 1 }, + CallSite { source: first, line: 2, column: 11 }, + CallSite { source: first + 1, line: 3, column: 28 }, + CallSite { source: UNKNOWN_SOURCE, line: 1, column: 1 }, + ] + ); + seen.borrow_mut().clear(); + // A getter a built-in calls is at the place of the code that called the built-in. + assert_eq!(r.run("\nObject.getOwnPropertyDescriptor(host, 'site').get.call(host)").unwrap(), JsValue::Number(2.0)); + assert_eq!(seen.borrow().first().map(|s| s.source), Some(r.script_tag())); +} + #[test] fn a_text_function_takes_each_argument_as_string_gives_it() { let mut r = realm(1_000_000); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 1c121a212b..1561d71dfe 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -80,7 +80,7 @@ pub use ast::{ }; pub use builtins::extensions::Localization; pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; -pub use host::{Arg, Data, DataLimits, HostError, HostFunction, HostItem, ScriptItem}; +pub use host::{Arg, CallSite, Data, DataLimits, HostError, HostFunction, HostItem, ScriptItem, SiteFunction}; pub use lexer::{Goal, Lexer}; pub use number::to_string as number_to_string; pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, SourceLocator, UNKNOWN_SOURCE}; diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 8d83162a50..f49c94eb46 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -7,6 +7,7 @@ //! each new node, name, reference or output unit is charged to the heap before it is made. use std::collections::{HashMap, HashSet}; +use std::hash::Hash; use std::rc::Rc; use crate::LimitKind; @@ -18,7 +19,7 @@ use crate::heap::{ XmlListData, XmlName, XmlNode, XmlNs, key, string_bytes, }; use crate::interp::{Abrupt, ErrorType, HeldBytes, Interp, JsResult}; -use crate::string_builtins::{UNITS_PER_FUEL, flat_charged}; +use crate::string_builtins::{UNITS_PER_FUEL, flat_charged, push_held, reserve_units}; use crate::value::{Str, Value, index_key}; fn parser_units(s: &str) -> Vec { @@ -295,26 +296,40 @@ fn name_bytes(n: &XmlName) -> usize { string_bytes(n.local.len()) + n.uri.as_ref().map_or(0, |u| string_bytes(u.len())) + n.prefix.as_ref().map_or(0, |p| string_bytes(p.len())) } -/// The namespace `stack` declares for `uri`, nearest first, skipping prefixes redeclared nearer. -/// `prefer` picks that prefix when several match; `need_prefix` rejects the default namespace. -fn ns_lookup(stack: &[XmlNs], uri: &Key, prefer: Option<&Key>, need_prefix: bool) -> Option { - let mut seen: HashSet<&[u16]> = HashSet::new(); - let mut best = None; - for ns in stack.iter().rev() { - let Some(p) = &ns.prefix else { continue }; - if !seen.insert(p) { - continue; - } - if ns.uri == *uri && !(need_prefix && p.is_empty()) { - if prefer == Some(p) { - return Some(ns.clone()); - } - if best.is_none() { - best = Some(ns.clone()); - } - } +/// Inserts `item` into `set`, first holding an upper estimate of the bytes of the set's new table +/// when it must grow. Returns whether `item` was new. +fn insert_held(interp: &mut Interp, held: &mut HeldBytes, set: &mut HashSet, item: T) -> JsResult { + if set.len() == set.capacity() && !set.contains(&item) { + let more = set.capacity().max(4); + held.add(interp, more.saturating_mul(size_of::() + 1).saturating_mul(16) / 7)?; + set.reserve(more); } - best + Ok(set.insert(item)) +} + +/// Makes room in `list` for `more` items, first holding the bytes of its new capacity when it must +/// grow. The capacity at least doubles, as a push's would. +fn reserve_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, more: usize) -> JsResult<()> { + if list.capacity() - list.len() < more { + let cap = list.len().saturating_add(more).max(list.capacity().saturating_mul(2)); + held.add(interp, (cap - list.capacity()).saturating_mul(size_of::()))?; + list.reserve_exact(cap - list.len()); + } + Ok(()) +} + +/// The number `n` of a prefix written `ns` in decimal without leading zeros, as +/// [`Interp::fresh_prefix`] makes them. +fn prefix_number(prefix: &[u16]) -> Option { + let digits = prefix.strip_prefix(&[u16::from(b'n'), u16::from(b's')])?; + let (&first, _) = digits.split_first()?; + if digits.len() > 9 || (first == u16::from(b'0') && digits.len() > 1) { + return None; + } + digits.iter().try_fold(0usize, |n, &c| { + let d = c.checked_sub(u16::from(b'0')).filter(|d| *d < 10)?; + Some(n * 10 + usize::from(d)) + }) } fn prefix_in_scope<'a>(stack: &'a [XmlNs], prefix: &[u16]) -> Option<&'a XmlNs> { @@ -345,18 +360,6 @@ fn units_are(units: &[u16], text: &str) -> bool { units.iter().copied().eq(text.encode_utf16()) } -/// A prefix no entry of `stack` uses. -fn fresh_prefix(stack: &[XmlNs]) -> Key { - let mut n = 0u32; - loop { - let candidate = key(&format!("ns{n}")); - if prefix_in_scope(stack, &candidate).is_none() { - return candidate; - } - n += 1; - } -} - /// One open element while serializing. struct OutFrame { node: ObjId, @@ -579,13 +582,16 @@ impl Interp { // ---- names ---- - fn name_from_string(&mut self, s: &[u16]) -> JsResult { + /// The name `s` gives: an attribute name after `@`, which is copied after its units are + /// reserved, and otherwise an element name that shares `s`. + fn name_from_string(&mut self, s: &Key) -> JsResult { if let Some((&0x40, rest)) = s.split_first() { let uri = if rest == STAR { None } else { Some(Rc::from(Vec::new())) }; + reserve_units(self, rest.len())?; return Ok(NameRef { uri, local: Rc::from(rest), attribute: true }); } - let uri = if s == STAR { None } else { Some(self.xml_default_uri()?) }; - Ok(NameRef { uri, local: Rc::from(s), attribute: false }) + let uri = if **s == *STAR { None } else { Some(self.xml_default_uri()?) }; + Ok(NameRef { uri, local: s.clone(), attribute: false }) } /// ToXMLName, with a canonical array index read as a position (ECMA-357 section 10.6). @@ -593,7 +599,7 @@ impl Interp { match k { Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 && *n < 4_294_967_295.0 => Ok(Access::Index(*n as u32)), Value::String(s) => { - let flat = s.flat(); + let flat = flat_charged(self, s)?; if let Some(i) = canonical_index(&flat) { return Ok(Access::Index(i)); } @@ -603,7 +609,8 @@ impl Interp { if let Some(Class::QName { name, attribute, .. }) = self.heap.object(*o).map(|ob| &ob.class) { return Ok(Access::Name(NameRef { uri: name.uri.clone(), local: name.local.clone(), attribute: *attribute })); } - let s = self.to_str(k)?.flat(); + let s = self.to_str(k)?; + let s = flat_charged(self, &s)?; if let Some(i) = canonical_index(&s) { return Ok(Access::Index(i)); } @@ -787,31 +794,33 @@ impl Interp { if n.attribute { let count = self.xml_node(x)?.attrs.len(); self.xml_take_fuel(count)?; - let attrs = std::mem::take(&mut self.xml_node_mut(x)?.attrs); - let mut kept = Vec::with_capacity(attrs.len()); - for a in attrs { + let mut kept = 0; + for k in 0..count { + let Some(a) = self.xml_node(x)?.attrs.get(k).copied() else { break }; let hit = self.xml_node(a)?.name.as_ref().is_some_and(|q| n.matches(q)); if hit { self.xml_node_mut(a)?.parent = None; } else { - kept.push(a); + self.xml_node_mut(x)?.attrs.swap(kept, k); + kept += 1; } } - self.xml_node_mut(x)?.attrs = kept; + self.xml_node_mut(x)?.attrs.truncate(kept); return Ok(()); } let count = self.children_len(x)?; self.xml_take_fuel(count)?; - let children = std::mem::take(&mut self.xml_node_mut(x)?.children); - let mut kept = Vec::with_capacity(children.len()); - for c in children { + let mut kept = 0; + for k in 0..count { + let Some(c) = self.child_at(x, k)? else { break }; if n.matches_child(self.xml_node(c)?) { self.xml_node_mut(c)?.parent = None; } else { - kept.push(c); + self.xml_node_mut(x)?.children.swap(kept, k); + kept += 1; } } - self.xml_node_mut(x)?.children = kept; + self.xml_node_mut(x)?.children.truncate(kept); Ok(()) } @@ -939,7 +948,7 @@ impl Interp { self.use_fuel()?; cur = self.xml_node(a)?.parent; } - let height = self.node_height(v)?; + let height = self.rooted(&[Value::Object(x), Value::Object(v)], |i| i.node_height(v))?; if depth.saturating_add(height) > self.options.max_depth { return Err(Abrupt::Limit(LimitKind::Heap)); } @@ -971,7 +980,9 @@ impl Interp { /// The levels of the tree at `root`, counting it. fn node_height(&mut self, root: ObjId) -> JsResult { - let mut stack = vec![(root, 0usize)]; + let mut held = HeldBytes::new(self); + let mut stack = Vec::new(); + push_held(self, &mut held, &mut stack, (root, 0usize))?; let mut height = 1; while let Some(&(node, next)) = stack.last() { self.use_fuel()?; @@ -980,7 +991,7 @@ impl Interp { if let Some(top) = stack.last_mut() { top.1 += 1; } - stack.push((c, 0)); + push_held(self, &mut held, &mut stack, (c, 0))?; height = height.max(stack.len()); } None => { @@ -1049,19 +1060,22 @@ impl Interp { } /// Takes the fuel and heap to put every item of `l` under `x` and returns the nodes to place, - /// from [`Self::child_for_item`]. The tree does not change here, so a limit leaves it as it - /// was. - fn reserve_list_insert(&mut self, x: ObjId, l: ObjId) -> JsResult> { + /// from [`Self::child_for_item`], with the hold on the list of them, which the caller keeps + /// until they are placed. The tree does not change here, so a limit leaves it as it was. + fn reserve_list_insert(&mut self, x: ObjId, l: ObjId) -> JsResult<(Vec, HeldBytes)> { + let count = self.list_len(l)?; + let roots = [Value::Object(x), Value::Object(l)]; + let mut held = HeldBytes::new(self); + self.rooted(&roots, |i| held.add(i, size_of::().saturating_mul(count)))?; let mut items = self.xml_list(l)?.items.clone(); let bytes = XML_REF_BYTES.saturating_mul(items.len()); - let roots = [Value::Object(x), Value::Object(l)]; self.rooted(&roots, |i| i.xml_charge(bytes))?; let len = self.children_len(x)?; for (j, item) in items.iter_mut().enumerate() { *item = self.child_for_item(*item, &roots)?; self.place_fuel(len + j, *item)?; } - Ok(items) + Ok((items, held)) } /// Puts `items` at `at` in order, after [`Self::reserve_list_insert`]. @@ -1100,7 +1114,7 @@ impl Interp { self.insert_node(x, at, *v)?; } Content::List(l) => { - let items = self.reserve_list_insert(x, *l)?; + let (items, _held) = self.reserve_list_insert(x, *l)?; self.place_children(x, at, &items)?; } } @@ -1152,7 +1166,7 @@ impl Interp { } Content::List(l) => { self.check_attach(x, c)?; - let items = self.reserve_list_insert(x, *l)?; + let (items, _held) = self.reserve_list_insert(x, *l)?; let len = self.children_len(x)?; self.xml_text_fuel(len)?; if at < len { @@ -1229,7 +1243,8 @@ impl Interp { s.push_units(self, &[SPACE])?; } let Some(item) = self.list_at(*l, i)? else { break }; - let text = self.node_to_str(item)?.flat(); + let text = self.node_to_str(item)?; + let text = flat_charged(self, &text)?; s.push_units(self, &text)?; } let Value::String(s) = s.finish(self) else { return Err(Abrupt::Internal("not a string".into())) }; @@ -1240,9 +1255,9 @@ impl Interp { let mut found = None; let count = self.xml_node(x)?.attrs.len(); self.xml_take_fuel(count)?; - let attrs = std::mem::take(&mut self.xml_node_mut(x)?.attrs); - let mut kept = Vec::with_capacity(attrs.len()); - for a in attrs { + let mut kept = 0; + for k in 0..count { + let Some(a) = self.xml_node(x)?.attrs.get(k).copied() else { break }; let hit = self.xml_node(a)?.name.as_ref().is_some_and(|q| n.matches(q)); if hit && found.is_some() { self.xml_node_mut(a)?.parent = None; @@ -1250,10 +1265,11 @@ impl Interp { if hit { found = Some(a); } - kept.push(a); + self.xml_node_mut(x)?.attrs.swap(kept, k); + kept += 1; } } - self.xml_node_mut(x)?.attrs = kept; + self.xml_node_mut(x)?.attrs.truncate(kept); if let Some(a) = found { self.xml_node_mut(a)?.value = value; return Ok(()); @@ -1280,11 +1296,13 @@ impl Interp { } let len = self.children_len(x)?; self.xml_take_fuel(len)?; + self.xml_keep(x); + let mut held = HeldBytes::new(self); let mut matches = Vec::new(); for k in 0..len { let Some(child) = self.child_at(x, k)? else { break }; if n.matches_child(self.xml_node(child)?) { - matches.push(child); + push_held(self, &mut held, &mut matches, child)?; } } match c { @@ -1378,6 +1396,8 @@ impl Interp { /// The steps of [`Self::resolve_value`], setting `created` to the first element they add. fn resolve_value_adding(&mut self, x: ObjId, created: &mut Option) -> JsResult> { + self.xml_keep(x); + let mut held = HeldBytes::new(self); let mut chain = Vec::new(); let mut cur = x; let base = loop { @@ -1391,7 +1411,7 @@ impl Interp { if *name.local == *STAR { return Ok(None); } - chain.push(l); + push_held(self, &mut held, &mut chain, l)?; cur = target; }; let mut base = base; @@ -1615,6 +1635,9 @@ impl Interp { Some(parent) => self.index_in_parent(parent, item)?.map(|q| (parent, q)), None => None, }; + let count = self.list_len(c)?; + let mut held = HeldBytes::new(self); + self.rooted(&[Value::Object(x), Value::Object(c)], |i| held.add(i, size_of::().saturating_mul(count)))?; let mut items = self.xml_list(c)?.items.clone(); if place.is_some() { let roots = [Value::Object(x), Value::Object(c)]; @@ -1682,6 +1705,8 @@ impl Interp { // ---- copies ---- fn copy_shallow(&mut self, src: ObjId, parent: Option) -> JsResult { + let declared = self.xml_node(src)?.ns.len(); + self.xml_charge(XML_NS_BYTES.saturating_mul(declared))?; let node = self.xml_node(src)?; let copy = XmlNode { kind: node.kind, @@ -1692,11 +1717,11 @@ impl Interp { children: Vec::new(), ns: node.ns.clone(), }; - let attrs = node.attrs.clone(); - self.xml_charge(XML_NS_BYTES.saturating_mul(copy.ns.len()))?; let c = self.new_xml_node(copy)?; self.xml_keep(c); - for a in attrs { + let mut k = 0; + while let Some(a) = self.xml_node(src)?.attrs.get(k).copied() { + k += 1; self.use_fuel()?; let n = self.xml_node(a)?; let attr = XmlNode { @@ -1721,7 +1746,9 @@ impl Interp { let top = i.copy_shallow(root, None)?; i.xml_keep(top); let mark = i.temp_roots.len(); - let mut stack = vec![(root, top, 0usize)]; + let mut held = HeldBytes::new(i); + let mut stack = Vec::new(); + push_held(i, &mut held, &mut stack, (root, top, 0usize))?; while let Some(&(src, dst, next)) = stack.last() { i.use_fuel()?; i.temp_roots.truncate(mark); @@ -1733,7 +1760,7 @@ impl Interp { let c = i.copy_shallow(child, Some(dst))?; i.xml_charge_link(c)?; i.xml_node_mut(dst)?.children.push(c); - stack.push((child, c, 0)); + push_held(i, &mut held, &mut stack, (child, c, 0))?; } None => { stack.pop(); @@ -1778,9 +1805,18 @@ impl Interp { // ---- comparison ---- + /// The namespace, local name and value of the attribute `id`, flattening a value that is a + /// rope with [`flat_charged`]. + fn attr_key(&mut self, id: ObjId) -> JsResult<(Option, Key, Key)> { + let n = self.xml_node(id)?; + let name = n.name.as_ref().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; + let (uri, local, value) = (name.uri.clone(), name.local.clone(), n.value.clone()); + Ok((uri, local, flat_charged(self, &value)?)) + } + fn same_shallow(&mut self, a: ObjId, b: ObjId) -> JsResult { let (x, y) = (self.xml_node(a)?, self.xml_node(b)?); - if x.kind != y.kind || x.attrs.len() != y.attrs.len() || x.children.len() != y.children.len() || x.value.flat() != y.value.flat() { + if x.kind != y.kind || x.attrs.len() != y.attrs.len() || x.children.len() != y.children.len() || x.value.len() != y.value.len() { return Ok(false); } match (&x.name, &y.name) { @@ -1788,26 +1824,25 @@ impl Interp { (Some(p), Some(q)) if p.local == q.local && p.uri == q.uri => {} _ => return Ok(false), } - if x.attrs.is_empty() { + let (xv, yv) = (x.value.clone(), y.value.clone()); + if flat_charged(self, &xv)? != flat_charged(self, &yv)? { + return Ok(false); + } + let count = self.xml_node(a)?.attrs.len(); + if count == 0 { return Ok(true); } - let count = x.attrs.len(); self.xml_take_fuel(count)?; let mut held = HeldBytes::new(self); held.add(self, ATTR_COUNT_BYTES.saturating_mul(count))?; - let (x, y) = (self.xml_node(a)?, self.xml_node(b)?); - let (xa, ya) = (x.attrs.clone(), y.attrs.clone()); let mut counts: HashMap<(Option, Key, Key), usize> = HashMap::new(); - let attr_key = |i: &Self, id: ObjId| -> JsResult<(Option, Key, Key)> { - let n = i.xml_node(id)?; - let name = n.name.as_ref().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; - Ok((name.uri.clone(), name.local.clone(), n.value.flat())) - }; - for a in xa { - *counts.entry(attr_key(self, a)?).or_default() += 1; + for k in 0..count { + let Some(id) = self.xml_node(a)?.attrs.get(k).copied() else { break }; + *counts.entry(self.attr_key(id)?).or_default() += 1; } - for b in ya { - match counts.get_mut(&attr_key(self, b)?) { + for k in 0..count { + let Some(id) = self.xml_node(b)?.attrs.get(k).copied() else { break }; + match counts.get_mut(&self.attr_key(id)?) { Some(n) if *n > 0 => *n -= 1, _ => return Ok(false), } @@ -1884,7 +1919,8 @@ impl Interp { let (xk, yk) = (self.xml_kind(x)?, self.xml_kind(y)?); let textual = |k| matches!(k, XmlKind::Text | XmlKind::Attribute); if (textual(xk) && self.node_simple(y)?) || (textual(yk) && self.node_simple(x)?) { - return Ok(Some(self.node_to_str(x)?.flat() == self.node_to_str(y)?.flat())); + let (s, other) = (self.node_to_str(x)?, self.node_to_str(y)?); + return Ok(Some(flat_charged(self, &s)? == flat_charged(self, &other)?)); } return self.node_equals(x, y).map(Some); } @@ -1900,9 +1936,9 @@ impl Interp { if let Xv::Node(n) = self.classify(x) && self.node_simple(n)? { - let s = self.node_to_str(n)?.flat(); - let other = self.to_str(y)?.flat(); - return Ok(Some(s == other)); + let s = self.node_to_str(n)?; + let other = self.to_str(y)?; + return Ok(Some(flat_charged(self, &s)? == flat_charged(self, &other)?)); } } Ok(None) @@ -1951,7 +1987,9 @@ impl Interp { /// The nodes under `x` matching `n`, in document order ([[Descendants]], section 9.1.1.8). pub(crate) fn node_descendants(&mut self, x: ObjId, n: &NameRef, out: ObjId) -> JsResult<()> { - let mut stack = vec![(x, 0usize)]; + let mut held = HeldBytes::new(self); + let mut stack = Vec::new(); + push_held(self, &mut held, &mut stack, (x, 0usize))?; if n.attribute { self.collect_matching(x, n, out)?; } @@ -1969,14 +2007,16 @@ impl Interp { } else if n.matches_child(self.xml_node(child)?) { self.list_push(out, child)?; } - stack.push((child, 0)); + push_held(self, &mut held, &mut stack, (child, 0))?; } Ok(()) } /// Merges adjacent text and removes empty text under `root` (ECMA-357 section 13.4.4.26). pub(crate) fn node_normalize(&mut self, root: ObjId) -> JsResult<()> { - let mut stack = vec![(root, 0usize)]; + let mut held = HeldBytes::new(self); + let mut stack = Vec::new(); + push_held(self, &mut held, &mut stack, (root, 0usize))?; while let Some(&(x, mut i)) = stack.last() { self.use_fuel()?; let Some(child) = self.child_at(x, i)? else { @@ -2010,7 +2050,7 @@ impl Interp { frame.1 = i + 1; } if kind == XmlKind::Element { - stack.push((child, 0)); + push_held(self, &mut held, &mut stack, (child, 0))?; } } Ok(()) @@ -2032,7 +2072,8 @@ impl Interp { let Some(c) = self.child_at(x, i)? else { break }; let node = self.xml_node(c)?; if !matches!(node.kind, XmlKind::Comment | XmlKind::Pi) { - let text = node.value.flat(); + let text = node.value.clone(); + let text = flat_charged(self, &text)?; out.push_units(self, &text)?; } } @@ -2069,7 +2110,8 @@ impl Interp { self.use_fuel()?; let Some(item) = self.list_at(l, i)? else { break }; if !matches!(self.xml_kind(item)?, XmlKind::Comment | XmlKind::Pi) { - let text = self.node_to_str(item)?.flat(); + let text = self.node_to_str(item)?; + let text = flat_charged(self, &text)?; out.push_units(self, &text)?; } } @@ -2107,6 +2149,46 @@ impl Interp { out.push_units(self, text.get(from..).unwrap_or_default()) } + /// The namespace `stack` declares for `uri`, nearest first, skipping prefixes redeclared nearer. + /// `prefer` picks that prefix when several match; `need_prefix` rejects the default namespace. + /// The set of prefixes it has passed is held against the heap limit. + fn ns_lookup(&mut self, stack: &[XmlNs], uri: &Key, prefer: Option<&Key>, need_prefix: bool) -> JsResult> { + let mut held = HeldBytes::new(self); + let mut seen: HashSet<&[u16]> = HashSet::new(); + let mut best = None; + for ns in stack.iter().rev() { + let Some(p) = &ns.prefix else { continue }; + if !insert_held(self, &mut held, &mut seen, &p[..])? { + continue; + } + if ns.uri == *uri && !(need_prefix && p.is_empty()) { + if prefer == Some(p) { + return Ok(Some(ns.clone())); + } + if best.is_none() { + best = Some(ns.clone()); + } + } + } + Ok(best) + } + + /// The first of `ns0`, `ns1`, ... that no entry of `stack` uses. One of the first + /// `stack.len() + 1` is free, so one pass over `stack` marks the used ones. + fn fresh_prefix(&mut self, stack: &[XmlNs]) -> JsResult { + self.xml_take_fuel(stack.len() + 1)?; + let mut held = HeldBytes::new(self); + held.add(self, stack.len() + 1)?; + let mut used = vec![false; stack.len() + 1]; + for ns in stack { + if let Some(slot) = ns.prefix.as_deref().and_then(prefix_number).and_then(|n| used.get_mut(n)) { + *slot = true; + } + } + let n = used.iter().position(|u| !u).unwrap_or(stack.len()); + Ok(key(&format!("ns{n}"))) + } + /// Picks the prefix of a name in `scope`, adding a declaration to `scope` when none is there. fn out_prefix(&mut self, name: &XmlName, scope: &mut Vec, attribute: bool) -> JsResult { self.xml_take_fuel(scope.len() + 1)?; @@ -2122,13 +2204,13 @@ impl Interp { if *uri == *key(XML_URI) { return Ok(key("xml")); } - if let Some(ns) = ns_lookup(scope, &uri, name.prefix.as_ref(), attribute) { + if let Some(ns) = self.ns_lookup(scope, &uri, name.prefix.as_ref(), attribute)? { return Ok(ns.prefix.unwrap_or(empty)); } let prefix = match &name.prefix { Some(p) if !(attribute && p.is_empty()) && prefix_in_scope(scope, p).is_none() => p.clone(), _ if !attribute && prefix_in_scope(scope, &empty).is_none() => empty, - _ => fresh_prefix(scope), + _ => self.fresh_prefix(scope)?, }; self.xml_charge(XML_NS_BYTES)?; scope.push(XmlNs { prefix: Some(prefix.clone()), uri }); @@ -2143,8 +2225,7 @@ impl Interp { out.push_units(self, local) } - /// Writes `x`; an element with children is left open on `stack`. - #[allow(clippy::too_many_arguments)] + /// Writes `x`, and returns the frame of an element with children, which is left open. fn write_enter( &mut self, out: &mut Builder, @@ -2153,11 +2234,11 @@ impl Interp { settings: &Settings, pretty: bool, scope: &mut Vec, - stack: &mut Vec, - ) -> JsResult<()> { + ) -> JsResult> { self.use_fuel()?; let node = self.xml_node(x)?; - let (kind, value) = (node.kind, node.value.flat()); + let (kind, value) = (node.kind, node.value.clone()); + let value = flat_charged(self, &value)?; self.xml_text_fuel(value.len())?; if pretty { out.push_repeated(self, &[SPACE], indent)?; @@ -2165,13 +2246,18 @@ impl Interp { match kind { XmlKind::Text => { let trimmed: &[u16] = if pretty { trim_xml_space(&value) } else { &value }; - return self.write_escaped(out, trimmed, false); + self.write_escaped(out, trimmed, false)?; + return Ok(None); + } + XmlKind::Attribute => { + self.write_escaped(out, &value, true)?; + return Ok(None); } - XmlKind::Attribute => return self.write_escaped(out, &value, true), XmlKind::Comment => { out.push_str(self, ""); + out.push_str(self, "-->")?; + return Ok(None); } XmlKind::Pi => { let target = self.xml_node(x)?.name.as_ref().map(|n| n.local.clone()).unwrap_or_else(|| Rc::from(Vec::new())); @@ -2179,47 +2265,46 @@ impl Interp { out.push_units(self, &target)?; out.push_units(self, &[SPACE])?; out.push_units(self, &value)?; - return out.push_str(self, "?>"); + out.push_str(self, "?>")?; + return Ok(None); } XmlKind::Element => {} } let ns_mark = scope.len(); let node = self.xml_node(x)?; - let declared = node.ns.clone(); - let attrs = node.attrs.clone(); + let declared = node.ns.len(); let name = node.name.clone().ok_or_else(|| Abrupt::Internal("element without a name".into()))?; - self.xml_take_fuel(declared.len().saturating_mul(scope.len() + 1))?; - let mut unnamed = Vec::new(); - for ns in declared { - if ns.prefix.is_none() { - unnamed.push(ns); - } else if !scope.iter().any(|a| a.uri == ns.uri && a.prefix == ns.prefix) { - self.xml_charge(XML_NS_BYTES)?; - scope.push(ns); - } - } - for ns in unnamed { - if !scope.iter().any(|a| a.uri == ns.uri) { - let prefix = fresh_prefix(scope); - self.xml_charge(XML_NS_BYTES)?; - scope.push(XmlNs { prefix: Some(prefix), uri: ns.uri }); + self.xml_take_fuel(declared.saturating_mul(scope.len() + 1))?; + for unnamed in [false, true] { + let mut k = 0; + while let Some(ns) = self.xml_node(x)?.ns.get(k).cloned() { + k += 1; + if ns.prefix.is_none() != unnamed { + continue; + } + if !unnamed && !scope.iter().any(|a| a.uri == ns.uri && a.prefix == ns.prefix) { + self.xml_charge(XML_NS_BYTES)?; + scope.push(ns); + } else if unnamed && !scope.iter().any(|a| a.uri == ns.uri) { + let prefix = self.fresh_prefix(scope)?; + self.xml_charge(XML_NS_BYTES)?; + scope.push(XmlNs { prefix: Some(prefix), uri: ns.uri }); + } } } let prefix = self.out_prefix(&name, scope, false)?; - let mut attr_prefixes = Vec::new(); - for &a in &attrs { - self.use_fuel()?; - let an = self.xml_node(a)?.name.clone().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; - attr_prefixes.push(self.out_prefix(&an, scope, true)?); - } out.push_units(self, &[LT])?; self.write_name(out, &prefix, &name.local)?; - for (&a, attr_prefix) in attrs.iter().zip(&attr_prefixes) { - let an = self.xml_node(a)?; - let local = an.name.as_ref().map(|q| q.local.clone()).unwrap_or_else(|| Rc::from(Vec::new())); - let value = an.value.flat(); + let mut k = 0; + while let Some(a) = self.xml_node(x)?.attrs.get(k).copied() { + k += 1; + self.use_fuel()?; + let an = self.xml_node(a)?.name.clone().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; + let attr_prefix = self.out_prefix(&an, scope, true)?; + let value = self.xml_node(a)?.value.clone(); + let value = flat_charged(self, &value)?; out.push_units(self, &[SPACE])?; - self.write_name(out, attr_prefix, &local)?; + self.write_name(out, &attr_prefix, &an.local)?; out.push_units(self, &[EQ, DQUOTE])?; self.write_escaped(out, &value, true)?; out.push_units(self, &[DQUOTE])?; @@ -2239,7 +2324,8 @@ impl Interp { let count = self.children_len(x)?; if count == 0 { scope.truncate(ns_mark); - return out.push_str(self, "/>"); + out.push_str(self, "/>")?; + return Ok(None); } out.push_units(self, &[GT])?; let first_is_text = match self.child_at(x, 0)? { @@ -2248,14 +2334,17 @@ impl Interp { }; let pretty_children = pretty && (count > 1 || !first_is_text); let child_indent = if pretty_children { indent.saturating_add(u64::from(settings.indent)) } else { 0 }; - stack.push(OutFrame { node: x, next: 0, indent, child_indent, pretty_children, ns_mark, prefix, local: name.local }); - Ok(()) + Ok(Some(OutFrame { node: x, next: 0, indent, child_indent, pretty_children, ns_mark, prefix, local: name.local })) } + /// Writes the tree at `root`. Its open elements are held against the heap limit. fn write_node(&mut self, out: &mut Builder, root: ObjId, indent: u64, settings: &Settings, pretty: bool) -> JsResult<()> { let mut scope: Vec = Vec::new(); + let mut held = HeldBytes::new(self); let mut stack: Vec = Vec::new(); - self.write_enter(out, root, indent, settings, pretty, &mut scope, &mut stack)?; + if let Some(frame) = self.write_enter(out, root, indent, settings, pretty, &mut scope)? { + push_held(self, &mut held, &mut stack, frame)?; + } while let Some(frame) = stack.last_mut() { let (node, next, child_indent, pretty_children) = (frame.node, frame.next, frame.child_indent, frame.pretty_children); frame.next += 1; @@ -2264,7 +2353,9 @@ impl Interp { if pretty_children { out.push_units(self, &[LF])?; } - self.write_enter(out, child, child_indent, settings, pretty && pretty_children, &mut scope, &mut stack)?; + if let Some(frame) = self.write_enter(out, child, child_indent, settings, pretty && pretty_children, &mut scope)? { + push_held(self, &mut held, &mut stack, frame)?; + } } None => { let Some(frame) = stack.pop() else { break }; @@ -2311,8 +2402,11 @@ impl Interp { self.throw(ErrorType::Syntax, message) } - fn decode_into(&mut self, src: &[u16], from: usize, to: usize, out: &mut Vec, attribute: bool) -> JsResult<()> { + /// Decodes `src[from..to]` onto `out`, holding the room it takes in `out` with `held` first. The + /// decoded text is never longer than the source. + fn decode_into(&mut self, src: &[u16], from: usize, to: usize, out: &mut Vec, attribute: bool, held: &mut HeldBytes) -> JsResult<()> { self.xml_text_fuel(to.saturating_sub(from))?; + reserve_held(self, held, out, to.saturating_sub(from))?; let mut pos = from; while pos < to { let Some(&c) = src.get(pos) else { break }; @@ -2427,7 +2521,16 @@ impl Interp { Ok(()) } - fn flush_text(&mut self, text: &mut Vec, ignore_whitespace: bool, open: &[ObjId], roots: &mut Vec) -> JsResult<()> { + /// Makes a text node of `text`, unless it is empty or ignored white space. Taking the buffer + /// releases `text_held`, its hold. + fn flush_text( + &mut self, + text: &mut Vec, + text_held: &mut HeldBytes, + ignore_whitespace: bool, + open: &[ObjId], + roots: &mut Vec, + ) -> JsResult<()> { if text.is_empty() { return Ok(()); } @@ -2435,16 +2538,22 @@ impl Interp { text.clear(); return Ok(()); } - let t = self.new_text_node(Str::from_units(std::mem::take(text)), None)?; + let value = Str::from_units(std::mem::take(text)); + *text_held = HeldBytes::new(self); + let t = self.new_text_node(value, None)?; self.place_parsed(t, open, roots) } + /// Parses the open tag at `src[pos]` and returns the offset after it. An element left open is + /// pushed onto `open` and its name onto `names`, both held with `stack_held`. The attributes, + /// sets and copies it works with are held until it returns. fn parse_open_tag( &mut self, src: &[u16], pos: usize, open: &mut Vec, names: &mut Vec<(usize, usize)>, + stack_held: &mut HeldBytes, roots: &mut Vec, ) -> JsResult { if open.len() >= self.options.max_depth { @@ -2455,6 +2564,7 @@ impl Interp { let raw_name = src.get(name_start..name_end).unwrap_or_default(); let (elem_prefix, elem_local) = self.split_name(raw_name)?; let mut at = name_end; + let mut held = HeldBytes::new(self); let mut raw_attrs: Vec<(usize, usize, Vec)> = Vec::new(); let closes = loop { let before = at; @@ -2492,27 +2602,31 @@ impl Interp { return Err(self.syntax("'<' cannot appear in an attribute value")); } let mut value = Vec::new(); - self.decode_into(src, value_start, value_end, &mut value, true)?; - raw_attrs.push((at, end, value)); + self.decode_into(src, value_start, value_end, &mut value, true, &mut held)?; + push_held(self, &mut held, &mut raw_attrs, (at, end, value))?; at = value_end + 1; } } }; self.xml_charge(XML_NS_BYTES.saturating_mul(raw_attrs.len()))?; - let mut ns: Vec = Vec::new(); - let mut seen_decl: HashSet> = HashSet::new(); + let xmlns = key("xmlns"); + let declares = |raw: &[u16]| raw == &xmlns[..] || raw.strip_prefix(&xmlns[..]).is_some_and(|rest| rest.first() == Some(&COLON)); + let decls = raw_attrs.iter().filter(|(start, end, _)| declares(src.get(*start..*end).unwrap_or_default())).count(); + let mut ns: Vec = Vec::with_capacity(decls); + let mut seen_decl: HashSet<&[u16]> = HashSet::new(); for (start, end, value) in &raw_attrs { let raw = src.get(*start..*end).unwrap_or_default(); - if raw == key("xmlns").as_ref() { + if raw == &xmlns[..] { if !ns_binding_allowed(&[], value) { return Err(self.syntax(RESERVED_NS_BINDING)); } - if !seen_decl.insert(Vec::new()) { + if !insert_held(self, &mut held, &mut seen_decl, &[] as &[u16])? { return Err(self.syntax("a namespace is declared twice")); } + self.xml_charge(string_bytes(0).saturating_add(string_bytes(value.len())))?; ns.push(XmlNs { prefix: Some(Rc::from(Vec::new())), uri: Rc::from(value.as_slice()) }); } else if let (Some(p), local) = self.split_name(raw)? - && p == key("xmlns").as_ref() + && p == &xmlns[..] { if value.is_empty() { return Err(self.syntax("a namespace prefix cannot be declared with an empty URI")); @@ -2520,9 +2634,10 @@ impl Interp { if !ns_binding_allowed(local, value) { return Err(self.syntax(RESERVED_NS_BINDING)); } - if !seen_decl.insert(local.to_vec()) { + if !insert_held(self, &mut held, &mut seen_decl, local)? { return Err(self.syntax("a namespace is declared twice")); } + self.xml_charge(string_bytes(local.len()).saturating_add(string_bytes(value.len())))?; ns.push(XmlNs { prefix: Some(Rc::from(local)), uri: Rc::from(value.as_slice()) }); } } @@ -2547,7 +2662,8 @@ impl Interp { ns, })?; self.place_parsed(element, open, roots)?; - let mut unique: HashSet<(Key, Vec)> = HashSet::new(); + let mut unique: HashSet<(Key, &[u16])> = HashSet::new(); + let mut own: Option> = None; for (start, end, value) in raw_attrs { self.use_fuel()?; let raw = src.get(start..end).unwrap_or_default(); @@ -2557,15 +2673,19 @@ impl Interp { } let (auri, stored_prefix): (Key, Key) = match prefix { Some(p) => { - let own = self.xml_node(element)?.ns.clone(); - match self.resolve_prefix(p, &own, open)? { + if own.is_none() { + let count = self.xml_node(element)?.ns.len(); + held.add(self, size_of::().saturating_mul(count))?; + own = Some(self.xml_node(element)?.ns.clone()); + } + match self.resolve_prefix(p, own.as_deref().unwrap_or_default(), open)? { Some(u) => (u, Rc::from(p)), None => return Err(self.syntax("an XML name uses an undeclared namespace prefix")), } } None => (Rc::from(Vec::new()), Rc::from(Vec::new())), }; - if !unique.insert((auri.clone(), local.to_vec())) { + if !insert_held(self, &mut held, &mut unique, (auri.clone(), local))? { return Err(self.type_error("an XML element has two attributes with the same name")); } let a = self.new_xml_node(XmlNode { @@ -2581,8 +2701,8 @@ impl Interp { self.xml_node_mut(element)?.attrs.push(a); } if !closes { - open.push(element); - names.push((name_start, name_end)); + push_held(self, stack_held, open, element)?; + push_held(self, stack_held, names, (name_start, name_end))?; } Ok(at) } @@ -2605,10 +2725,14 @@ impl Interp { } /// Parses XML text into nodes, kept reachable through the temporary roots until the caller's - /// scope ends. + /// scope ends. The open elements and their names, the text being gathered and each tag's + /// attributes are held as they grow. The allowance held from the source's length covers the + /// copy a value's units take as it becomes a string, before the node that holds it is charged. pub(crate) fn xml_parse(&mut self, source: &[u16]) -> JsResult> { let mut held = HeldBytes::new(self); held.add(self, string_bytes(source.len()).saturating_mul(2))?; + let mut stack_held = HeldBytes::new(self); + let mut text_held = HeldBytes::new(self); let settings = self.xml_settings()?; let mut src = source; let offset_start = skip_xml_space(src, 0); @@ -2638,7 +2762,7 @@ impl Interp { self.xml_text_fuel(end - pos)?; return Err(self.syntax("']]>' cannot appear in XML text")); } - self.decode_into(src, pos, end, &mut text, false)?; + self.decode_into(src, pos, end, &mut text, false, &mut text_held)?; pos = end; continue; } @@ -2650,11 +2774,13 @@ impl Interp { }; self.xml_text_fuel(end - pos)?; self.check_units(src, pos + 9, end)?; - text.extend_from_slice(src.get(pos + 9..end).unwrap_or_default()); + let section = src.get(pos + 9..end).unwrap_or_default(); + reserve_held(self, &mut text_held, &mut text, section.len())?; + text.extend_from_slice(section); pos = end + 3; continue; } - self.flush_text(&mut text, settings.ignore_whitespace, &open, &mut roots)?; + self.flush_text(&mut text, &mut text_held, settings.ignore_whitespace, &open, &mut roots)?; if rest.starts_with(&parser_units("