diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 784c8d981f..d9e179286f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -236,7 +236,8 @@ fn lcm(a: usize, b: usize) -> usize { a.checked_div(x).map_or(0, |q| q * b) } -/// Interpolate width profiles (none is uniform) at every position either has a width point. +/// Interpolate width profiles (none is uniform) at every position either has a width point, +/// keeping their discontinuous points (a position where either steps gives two points). fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> Option { if x.is_none() && y.is_none() { return None; @@ -245,22 +246,45 @@ fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> O let (x, y) = (x.unwrap_or(&uniform), y.unwrap_or(&uniform)); let mut at: Vec = x.points.iter().chain(&y.points).map(|p| p.0).filter(|p| p.is_finite()).collect(); at.sort_by(f64::total_cmp); - at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - let points = at - .into_iter() - .map(|p| { - let ((l1, r1), (l2, r2)) = (x.at(p), y.at(p)); - (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)) - }) - .collect(); + // Within the distance `around` reads a discontinuous point at, so a point near one keeps it. + at.dedup_by(|a, b| (*a - *b).abs() <= 1e-7); + // Each profile is a smooth curve through its own points: samples between the positions keep + // both curves' shapes where the other profile's points and steps change the tangents. Two + // profiles with points at the same positions need none (so a preset on both keys stays it). + let same_positions = x.points.len() == y.points.len() && x.points.iter().zip(&y.points).all(|(p, q)| (p.0 - q.0).abs() <= 1e-7); + let per_span = if same_positions { 1 } else { (4096 / at.len().max(1)).clamp(1, 8) }; + let mut points = Vec::with_capacity(at.len() * per_span + 2); + let mix = |p: f64, (l1, r1): (f64, f64), (l2, r2): (f64, f64)| (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)); + for (k, &p) in at.iter().enumerate() { + let ((xb, xa), (yb, ya)) = (x.around(p), y.around(p)); + points.push(mix(p, xb, yb)); + // A discontinuous point ends the curve on each side even where both widths match. + if steps_at(x, p) || steps_at(y, p) { + points.push(mix(p, xa, ya)); + } + if let Some(&q) = at.get(k + 1) { + for j in 1..per_span { + let f = p + (q - p) * j as f64 / per_span as f64; + points.push(mix(f, x.at(f), y.at(f))); + } + } + } Some(WidthProfile { points }) } +/// Whether width profile `w` has a discontinuous point at `p` (two points there, within `1e-7`, +/// as [`WidthProfile::around`] reads them). +fn steps_at(w: &WidthProfile, p: f64) -> bool { + let i = w.points.partition_point(|q| q.0 < p - 1e-7); + w.points.get(i..).unwrap_or_default().iter().take_while(|q| q.0 <= p + 1e-7).count() > 1 +} + /// The positions along the stroke where either of two pen recordings has a sample, in order. fn sample_positions(x: Option<&[(f64, f64)]>, y: Option<&[(f64, f64)]>) -> Vec { let mut at: Vec = x.unwrap_or_default().iter().chain(y.unwrap_or_default()).map(|p| p.0).filter(|p| p.is_finite()).collect(); at.sort_by(f64::total_cmp); - at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + // Within the distance `step_after` reads a step at, so a sample near a step keeps the step. + at.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); if at.is_empty() { at = vec![0.0, 1.0]; } @@ -487,6 +511,161 @@ fn reverse_closed(sp: &mut SubPath) { sp.anchors.rotate_right(1); } +/// How a key's subpaths were turned for its blend, so that its stroke's profiles, pen +/// recordings and arrowheads turn with it: position `u` along the turned subpath is position +/// `start + u` (`start - u` when `reversed`) along the key's own, wrapping round a closed one. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Orient { + start: f64, + reversed: bool, + closed: bool, +} + +impl Orient { + fn of(sp: &SubPath) -> Self { + Self { closed: sp.closed, ..Self::default() } + } + + /// The subpath now starts at the fraction `f` of its length. + fn rotate(&mut self, f: f64) { + self.start += if self.reversed { -f } else { f }; + } + + /// The subpath now runs the other way. + fn reverse(&mut self) { + self.start += if self.reversed { -1.0 } else { 1.0 }; + self.reversed = !self.reversed; + } + + /// As [`start_at`] turns `sp` (call it before). + fn start_at(&mut self, sp: &SubPath, i: usize) { + let n = sp.anchors.len(); + if i >= n { + return; + } + if sp.closed { + self.rotate(arc_fraction(sp, i)); + } else if i + 1 == n && n > 1 { + self.reverse(); + } + } + + /// Where a closed subpath now starts, as a fraction of its length; 0 for an open one. + fn seam(&self) -> f64 { + let s = if self.closed { self.start.rem_euclid(1.0) } else { 0.0 }; + if s > 1e-9 && s < 1.0 - 1e-9 { s } else { 0.0 } + } + + fn is_identity(&self) -> bool { + !self.reversed && self.seam() == 0.0 + } + + /// Whether two subpaths were turned alike, so one stroke along both turns the same way. + fn same_turn(&self, o: &Self) -> bool { + self.reversed == o.reversed && (self.seam() - o.seam()).abs() < 1e-9 + } +} + +/// How far along `sp` its anchor `i` sits, as a fraction of its length. +fn arc_fraction(sp: &SubPath, i: usize) -> f64 { + let pos = sp.anchor_arc_positions(); + match (pos.get(i), pos.last()) { + (Some(&at), Some(&total)) if total.is_finite() && total > 1e-12 => (at / total).clamp(0.0, 1.0), + _ => 0.0, + } +} + +/// Samples (position, value) along a key's subpath, read along the subpath turned as `o` says. +/// A closed subpath starting elsewhere starts the samples there too: the ones before it move +/// past the seam (`wrap` applied), which keeps any step the seam makes, and a value between +/// samples is read with `at`. A reversed subpath then reads them backwards (`flip` applied). +fn reoriented(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wrap: impl Fn(V) -> V, flip: impl Fn(V) -> V) -> Vec<(f64, V)> { + let s = o.seam(); + let mut out = points.to_vec(); + if s > 0.0 + && let (Some(&(_, first)), Some(&(_, last))) = (points.first(), points.last()) + { + let i = points.partition_point(|q| q.0 < s - 1e-9); + let j = points.partition_point(|q| q.0 <= s + 1e-9).max(i); + let (before, after) = match points.get(i..j) { + Some([p, .., q]) => (p.1, q.1), + Some([p]) => (p.1, p.1), + _ => (at(s), at(s)), + }; + out = Vec::with_capacity(points.len() + 4); + out.push((0.0, after)); + out.extend(points.get(j..).unwrap_or_default().iter().map(|&(t, v)| (t - s, v))); + out.push((1.0 - s, last)); + out.push((1.0 - s, wrap(first))); + out.extend(points.get(..i).unwrap_or_default().iter().map(|&(t, v)| (t + 1.0 - s, wrap(v)))); + out.push((1.0, wrap(before))); + } + if o.reversed { + out = out.into_iter().rev().map(|(t, v)| (1.0 - t, flip(v))).collect(); + } + out +} + +/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap +/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its +/// arrowheads. +fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { + if o.is_identity() { + return; + } + let same = |v: f64| v; + if let Some(p) = &mut s.profile { + let mut pts: Vec<(f64, (f64, f64))> = p.points.iter().map(|&(t, l, r)| (t, (l, r))).collect(); + // A width profile is a smooth curve through its points: where the new start splits a span, + // samples of that span keep its curve once its ends become ends of the profile. + let seam = o.seam(); + let i = p.points.partition_point(|q| q.0 < seam); + if seam > 0.0 + && let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.points.get(j)), p.points.get(i)) + && seam - a.0 > 1e-9 + && b.0 - seam > 1e-9 + { + const SAMPLES: usize = 32; + let (a, b) = (a.0, b.0); + let extra: Vec<(f64, (f64, f64))> = + (1..SAMPLES).map(|k| a + (b - a) * k as f64 / SAMPLES as f64).filter(|f| (f - seam).abs() > 1e-9).map(|f| (f, p.at(f))).collect(); + pts.splice(i..i, extra); + } + let out = reoriented(&pts, o, |f| p.at(f), |v| v, |(l, r)| (r, l)); + p.points = out.into_iter().map(|(t, (l, r))| (t, l, r)).collect(); + } + for p in [&mut s.pressure, &mut s.tilt].into_iter().flatten() { + let out = reoriented(&p.points, o, |f| p.at(f), same, same); + p.points = out; + } + for p in [&mut s.bearing, &mut s.rotation].into_iter().flatten() { + // The samples moved past the seam carry the whole turns the recording makes along the + // stroke, so the angle does not jump by them there. + let turns = match (p.points.first(), p.points.last()) { + (Some(a), Some(b)) if (b.1 - a.1).is_finite() => ((b.1 - a.1) / 360.0).round() * 360.0, + _ => 0.0, + }; + let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same); + p.points = out; + } + if o.reversed && !o.closed { + std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); + s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); + } +} + +/// `n` with every stroke read along its subpaths turned as `o` says. +fn reorient_strokes(n: &mut Node, o: Orient) { + if o.is_identity() { + return; + } + for it in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = it { + reorient_stroke(s, o); + } + } +} + /// Anchor positions of a subpath relative to its box, scaled to unit size. fn normalised(sp: &SubPath) -> Vec { let pts = sp.anchors.iter().map(|a| a.p); @@ -524,6 +703,8 @@ fn best_rotation(a: &SubPath, b: &SubPath) -> usize { pub struct PathPair { a: Vec, b: Vec, + /// How each path's subpaths were turned, where all of them turned alike (else unturned). + orient: (Orient, Orient), } impl PathPair { @@ -535,6 +716,7 @@ impl PathPair { let cb = b.bounds().map(|r| r.center()).unwrap_or_default(); let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed); let (mut va, mut vb) = (Vec::with_capacity(n), Vec::with_capacity(n)); + let mut orient = (Orient::default(), Orient::default()); for i in 0..n { let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) { (Some(x), Some(y)) => (x.clone(), y.clone()), @@ -542,17 +724,21 @@ impl PathPair { (None, Some(y)) => (degenerate(y, ca), y.clone()), (None, None) => continue, }; + let (mut oa, mut ob) = (Orient::of(&sa), Orient::of(&sb)); let explicit = i == 0 && (starts.0.is_some() || starts.1.is_some()); if explicit { if let Some(s) = starts.0 { + oa.start_at(&sa, s); start_at(&mut sa, s); } if let Some(s) = starts.1 { + ob.start_at(&sb, s); start_at(&mut sb, s); } } let closed = sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2; if closed && (sa.area() * sb.area()) < 0.0 { + ob.reverse(); reverse_closed(&mut sb); } let m = sa.anchors.len().max(sb.anchors.len()); @@ -560,12 +746,26 @@ impl PathPair { grow(&mut sb, m); if closed && !explicit { let r = best_rotation(&sa, &sb); + if r != 0 { + ob.rotate(arc_fraction(&sb, r)); + } sb.anchors.rotate_left(r); } + // One stroke runs along every subpath of a key, each reading it by its own length, so + // it turns only when all of the key's subpaths turned alike. + if i == 0 { + orient = (oa, ob); + } + if i < a.subpaths.len() && !orient.0.same_turn(&oa) { + orient.0 = Orient::default(); + } + if i < b.subpaths.len() && !orient.1.same_turn(&ob) { + orient.1 = Orient::default(); + } va.push(sa); vb.push(sb); } - Self { a: va, b: vb } + Self { a: va, b: vb, orient } } /// The path at `t` (0 = `a`, 1 = `b`). @@ -680,7 +880,12 @@ impl Lerp { _ => Shape::Other, }, }; - Self { a: a.clone(), b: b.clone(), shape } + let (mut a, mut b) = (a.clone(), b.clone()); + if let Shape::Path { pair, .. } = &shape { + reorient_strokes(&mut a, pair.orient.0); + reorient_strokes(&mut b, pair.orient.1); + } + Self { a, b, shape } } /// The object at `t` (0 = `a`, 1 = `b`), with id 0. @@ -1597,6 +1802,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::appearance::Arrowhead; use vectorcraft_geom::{Rect, shapes}; #[test] @@ -1909,6 +2115,89 @@ mod tests { assert!(s.pressure.is_none() && s.tilt.is_none() && s.bearing.is_none() && s.rotation.is_none()); } + #[test] + fn a_point_near_the_other_keys_step_keeps_the_step() { + let flat = PressureProfile { points: vec![(0.0, 0.0), (0.4999995, 0.0), (1.0, 0.0)] }; + let stepped = PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] }; + let p = lerp_pressure(Some(&flat), Some(&stepped), 1.0).unwrap_or_default(); + assert!((p.at(0.75) - 1.0).abs() < 1e-9, "{p:?}"); + let narrow = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.4999995, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let stepped = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 0.0, 0.0), (1.0, 0.0, 0.0)] }; + let w = lerp_profile(Some(&narrow), Some(&stepped), 1.0).unwrap_or_default(); + assert!(w.at(0.75).0.abs() < 1e-9, "{w:?}"); + // The other key's step doesn't reshape a smooth profile's curve. + let early = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.25, 1.0, 1.0), (0.25, 0.5, 0.5), (1.0, 0.5, 0.5)] }; + let w = lerp_profile(Some(&WidthProfile::lens()), Some(&early), 0.0).unwrap_or_default(); + for u in [0.1, 0.125, 0.2, 0.3, 0.4] { + let (got, want) = (w.at(u).0, WidthProfile::lens().at(u).0); + assert!((got - want).abs() < 5e-3, "at {u}: {got} for {want}: {w:?}"); + } + let lens = WidthProfile::lens(); + assert_eq!(lerp_profile(Some(&lens), Some(&lens), 0.5).and_then(|p| p.preset_id()), Some("lens")); + } + + #[test] + fn stroke_profiles_turn_with_a_key_path_started_elsewhere() { + let keyed = |path: PathData, edit: &dyn Fn(&mut StrokeLayer)| { + let mut ap = Appearance::default(); + ap.set_stroke(Paint::solid(Color::BLACK)); + if let Some(s) = ap.stroke_mut() { + edit(s); + } + Node::path(NodeId(1), path, ap) + }; + let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + let pressure = |s: &StrokeLayer, f: f64| s.pressure.as_ref().map_or(PressureProfile::MID, |p| p.at(f)); + // An open key clicked at its last anchor runs the other way, and so do its pressure, its + // width profile (whose sides swap) and its arrowheads. + let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0)], false)); + let light = keyed(line(0.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 0.0)] })); + let ramp = keyed(line(100.0), &|s| { + s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] }); + s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 0.0, 0.0)] }); + s.start_arrow = Some(Arrowhead::Triangle); + s.arrow_scale = (200.0, 100.0); + }); + let blend = Lerp::new(&light, &ramp, (None, Some(1))); + let s = stroke(&blend.at(0.25)); + assert!(pressure(&s, 0.0).abs() < 1e-9 && (pressure(&s, 1.0) - 0.25).abs() < 1e-9, "{:?}", s.pressure); + let s = stroke(&blend.at(1.0)); + assert_eq!(s.profile.as_ref().map(|p| p.at(1.0)), Some((0.0, 1.0)), "{:?}", s.profile); + assert_eq!((s.start_arrow, s.end_arrow, s.arrow_scale), (None, Some(Arrowhead::Triangle), (100.0, 200.0))); + // A key whose other subpaths keep their direction keeps its one stroke as it is. + let two = |x: f64| { + let sub = |y: f64| SubPath::polyline(&[Point::new(x, y), Point::new(x + 10.0, y)], false); + PathData::new(vec![sub(0.0), sub(20.0)]) + }; + let ramps = keyed(two(100.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] })); + let s = stroke(&Lerp::new(&keyed(two(0.0), &|_| {}), &ramps, (None, Some(1))).at(1.0)); + assert!((pressure(&s, 0.0) - 1.0).abs() < 1e-9, "{:?}", s.pressure); + // A closed key started half way round starts its recordings there too, keeping the step + // where its own start was and the whole turn its rotation makes. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)); + let plain = keyed(square(0.0), &|_| {}); + let turning = keyed(square(100.0), &|s| { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }); + s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] }); + s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }); + }); + let blend = Lerp::new(&plain, &turning, (Some(0), Some(2))); + let s = stroke(&blend.at(1.0)); + assert!((pressure(&s, 0.25) - 0.75).abs() < 1e-9 && (pressure(&s, 0.75) - 0.25).abs() < 1e-9, "{:?}", s.pressure); + let width = s.profile.as_ref().map_or((1.0, 1.0), |p| p.at(0.75)); + assert!((width.0 - 0.75).abs() < 1e-6 && (width.1 - 0.75).abs() < 1e-6, "the width steps where the key started: {:?}", s.profile); + // A width curve split by the new start keeps its shape. + let lensed = keyed(square(100.0), &|s| s.profile = Some(WidthProfile::lens())); + let s = stroke(&Lerp::new(&plain, &lensed, (Some(0), Some(1))).at(1.0)); + for u in [0.05, 0.125, 0.2, 0.6, 0.7, 0.74, 0.76, 0.8, 0.875, 0.95] { + let (got, want) = (s.profile.as_ref().map_or(1.0, |p| p.at(u).0), WidthProfile::lens().at((u + 0.25) % 1.0).0); + assert!((got - want).abs() < 2e-3, "at {u}: {got} for {want}: {:?}", s.profile); + } + let rot = |t: f64, f: f64| stroke(&blend.at(t)).rotation.as_ref().map_or(0.0, |p| p.unwrapped_at(f)); + assert!((vectorcraft_geom::normalize_deg(rot(1.0, 0.25)) + 90.0).abs() < 1e-6, "{:?}", s.rotation); + assert!((rot(0.5, 0.499) - rot(0.5, 0.501)).abs() < 1.0, "no jump where the key started: {:?}", stroke(&blend.at(0.5)).rotation); + } + #[test] fn odd_dash_blends_to_solid_with_closed_gaps() { let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap(); diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index bb3725072e..a2c2a66498 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -237,6 +237,10 @@ pub struct ParaStyle { /// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start /// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku. pub kinsoku_type: KinsokuShoriType, + /// Bunri-Kinshi (Paragraph panel menu): a line never breaks inside an inseparable pair (two em + /// dashes, two ellipses, two two-dot leaders, a vertical kana repeat mark's two halves). Kept + /// with kinsoku None, where it has no effect. Off by default. + pub bunri_kinshi: bool, /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. @@ -339,6 +343,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "crate::skip::is_default")] kinsoku_type: KinsokuShoriType, #[serde(default, skip_serializing_if = "crate::skip::is_default")] + bunri_kinshi: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, @@ -375,6 +381,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -401,6 +408,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -431,6 +439,7 @@ impl From for ParaStyle { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -456,6 +465,7 @@ impl From for ParaStyle { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 795fc69bdf..24f3d68af6 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -41,8 +41,8 @@ fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { fn clear_item_effects(item: &mut AppearanceItem) { match item { - AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)), - AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)), + AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), + AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), } } @@ -337,7 +337,7 @@ fn moved_type_group(n: &Node) -> Option { g.knockout = Knockout::Off; g.knockout_shape = n.knockout_shape; g.extras_mut().mask = n.extras().mask.clone(); - g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + g.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(g) } @@ -397,7 +397,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { return None; } reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; Some(m) @@ -439,7 +440,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); Some((g, rule, ctx, m)) } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index d22a4b06b3..031d3e1d98 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -94,6 +94,8 @@ fn lengths_of(id: &str) -> Lengths { "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), "brushStrokes.accentedEdges" => always(&["edgeWidth"]), "artistic.paletteKnife" => always(&["strokeSize"]), + "artistic.coloredPencil" => always(&["pencilWidth"]), + "artistic.paintDaubs" => always(&["brushSize"]), "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), "brushStrokes.spatter" => always(&["sprayRadius"]), "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), @@ -265,7 +267,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( @@ -387,7 +389,7 @@ pub fn effect_catalog() -> Vec { "artistic.paintDaubs", "Paint Daubs…", ARTISTIC, - "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + "{brushSize: 1..50 (10; spacing of the dab centers in document units),sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), ), r( diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 57f2fc8613..015490a74d 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -265,7 +265,7 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node { fn without_geometry_effects(n: &mut Node) { if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) { let mut ap = (*n.appearance).clone(); - ap.effects.retain(|e| !crate::is_geometry(&e.id)); + ap.effects = crate::raster::without_geometry(&ap.effects); n.appearance = ap.into(); } } @@ -794,7 +794,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { carry.apply(&mut m.appearance); } } - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); if paint { let shapes = member_shapes(doc, &m); let bounds = m.geometric_bounds(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8591bbab2c..80cfef7b96 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; +pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects, without_geometry}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index cfa51ca2b0..07140e2bc9 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -1,11 +1,13 @@ //! Raster (painted) effects: descriptions for the renderer. -use serde_json::Value; +use std::borrow::Cow; + +use serde_json::{Map, Value, json}; use vectorcraft_doc::color::{BlendMode, Color}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; @@ -384,9 +386,10 @@ fn opacity(p: &Value) -> f32 { (num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32 } -/// The visible raster effects of `effects`, in stack order. +/// The visible raster effects of `effects`, in stack order, each following the Transform effects +/// listed after it ([`followed`]). pub fn raster_effects(effects: &[Effect]) -> Vec { - effects + followed(effects) .iter() .filter(|e| e.visible) .filter_map(|e| { @@ -426,6 +429,116 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { .collect() } +/// `effects` with each raster effect following the visible Transform effects listed after it +/// ([`follow_transform`]): effects apply in the order they are listed, so a Transform effect +/// transforms what the raster effects before it paint, whatever its Scale Strokes & Effects says. +/// A hidden raster effect follows too, so it draws the same when it is shown after the Transform +/// effect has been expanded. A raster effect that a Transform effect making copies or moving at +/// random follows stays as it is, as does one after the last Transform effect. Borrowed when +/// nothing changes. +pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { + if !effects.iter().any(|e| e.visible && e.id == "distort.transform") { + return Cow::Borrowed(effects); + } + let mut out: Option> = None; + // The linear part of the Transform effects after the effect at hand, the last one outermost; + // `None` once one of them makes copies or moves at random. + let mut after = Some(Affine::IDENTITY); + for (i, e) in effects.iter().enumerate().rev() { + if e.id == "distort.transform" { + after = after.and_then(|a| transform_step(e).map(|s| a * s)); + continue; + } + let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue }; + let list = out.get_or_insert_with(|| effects.to_vec()); + if let Some(e) = list.get_mut(i) { + follow_transform(e, a); + } + } + out.map_or(Cow::Borrowed(effects), Cow::Owned) +} + +/// `effects` without their geometry effects, each raster effect following the Transform effects +/// it loses ([`followed`]): what an object, or a fill or stroke, keeps once its geometry effects +/// are applied to its art. +pub fn without_geometry(effects: &[Effect]) -> Vec { + followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect() +} + +/// Make the raster effects of `effects`, all listed before Transform effect `t`, follow `t` as +/// [`followed`] makes them follow it, for art that applies its effects one at a time. `false` +/// when `t` leaves them as they are. +pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool { + let Some(a) = transform_step(t).filter(|a| *a != Affine::IDENTITY) else { return false }; + let mut changed = false; + for e in effects.iter_mut().filter(|e| follows(&e.id)) { + follow_transform(e, a); + changed = true; + } + changed +} + +/// How many of the effects of `effects` that [`without_geometry`] keeps no longer follow the +/// Transform effects listed after `effects` ([`followed`]): those before the last visible Transform +/// effect among them that makes copies or moves at random. +pub(crate) fn held_back(effects: &[Effect]) -> usize { + let Some(last) = effects.iter().rposition(|e| transform_step(e).is_none()) else { return 0 }; + effects.get(..last).unwrap_or_default().iter().filter(|e| !crate::is_geometry(&e.id)).count() +} + +/// Does `e` stop the raster effects listed before it from following the Transform effects after +/// it ([`followed`]): is it a visible Transform effect that makes copies or moves at random? +pub(crate) fn holds_back(e: &Effect) -> bool { + transform_step(e).is_none() +} + +/// What `e` does to the raster effects listed before it ([`followed`]): the linear part of its map +/// when it is a visible Transform effect that moves the art once, the identity when it is no +/// Transform effect, is hidden or has Transform Objects off (the art stays where it is), and `None` +/// when it makes copies or moves at random. +fn transform_step(e: &Effect) -> Option { + if e.id != "distort.transform" || !e.visible { + return Some(Affine::IDENTITY); + } + let p = merged_params(&e.id, &e.params); + if !flag(&p, "objects", true) { + return Some(Affine::IDENTITY); + } + let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0); + (copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO)) +} + +/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG +/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room +/// around the art. +fn follows(id: &str) -> bool { + crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE +} + +/// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as +/// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean +/// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with +/// `a`. Effects that [`follows`] leaves out stay as they are. +fn follow_transform(e: &mut Effect, a: Affine) { + if !follows(&e.id) { + return; + } + let offset = (e.id == "stylize.dropShadow").then(|| { + let p = merged_params(&e.id, &e.params); + a * Point::new(num(&p, "x", 7.0), num(&p, "y", 7.0)) + }); + if let Some(k) = vectorcraft_doc::Scaling::factor(a) { + crate::scale_effect(e, k); + } + if let Some(o) = offset.filter(|o| o.x.is_finite() && o.y.is_finite()) { + if !e.params.is_object() { + e.params = Value::Object(Map::new()); + } + e.params["x"] = json!(o.x); + e.params["y"] = json!(o.y); + } +} + /// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected) /// geometry's, in document units. Geometry effects are accounted for by evaluating them. pub fn outset(effects: &[Effect], bounds: Rect) -> f64 { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 483ac44d6e..8822548c29 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -604,7 +604,7 @@ fn as_art_opts( out.knockout = vectorcraft_doc::Knockout::Off; out.knockout_shape = n.knockout_shape; out.extras_mut().mask = n.extras().mask.clone(); - out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(out) } @@ -944,7 +944,7 @@ impl Step<'_> { // The copies of a path replace it by a group of them, the path included. let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len); if options.reach_appearance() { - let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, None); *used = used.minus(before).plus(made); if moved { // With Transform Objects off the path stays, and its mask takes the @@ -960,7 +960,7 @@ impl Step<'_> { } if separate { let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; - let made = copy_leaf(leaf, &path, m, copies, scaling, room); + let made = copy_leaf(leaf, &path, m, copies, scaling, room, None); *used = used.minus(before).plus(made); moved_ids.insert(leaf.id); short |= counts(leaf.id) && copied(leaf) <= copies; @@ -1245,8 +1245,9 @@ fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::S /// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its /// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them /// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform -/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after, -/// and `false` when the path kept its shape because the moved path would pass `budget`. +/// Objects off the geometry stays and the options apply once. The effects `kept` names keep their +/// values on the leaf and on each copy ([`keep_listed`]). Returns what the leaf costs after, and +/// `false` when the path kept its shape because the moved path would pass `budget`. fn transform_leaf( leaf: &mut Node, path: &PathData, @@ -1254,6 +1255,7 @@ fn transform_leaf( copies: usize, options: crate::distort::TransformOptions, budget: Budget, + kept: Kept, ) -> (Budget, bool) { let scaling = copy_scaling(options); if !options.objects { @@ -1266,6 +1268,7 @@ fn transform_leaf( if options.patterns { vectorcraft_doc::pattern::transform_pattern_paints(leaf, m); } + keep_listed(leaf, kept); return (Budget::of(path), true); } if copies == 0 { @@ -1275,9 +1278,10 @@ fn transform_leaf( return (Budget::of(path), false); } leaf.transform(m, scaling); + keep_listed(leaf, kept); return (Budget::of(&moved), true); } - (copy_leaf(leaf, path, m, copies, scaling, budget), true) + (copy_leaf(leaf, path, m, copies, scaling, budget, kept), true) } /// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a @@ -1423,9 +1427,10 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: /// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the /// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass /// `budget` with what each copy's own geometry effects make ([`effects_cost`]), and before one's -/// strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the copies cost, the original -/// included. -fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { +/// strokes would scale past [`MAX_STROKE_SCALE`]. The effects `kept` names keep their values on +/// each copy, before its cost is counted ([`keep_listed`]). Returns what the copies cost, the +/// original included. +fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget, kept: Kept) -> Budget { let mut made = Budget::of(path).plus(effects_cost(leaf, path)); let mut placements: Vec> = vec![Arc::new(leaf.clone())]; let mut xf = Affine::IDENTITY; @@ -1438,6 +1443,7 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling } let mut copy = leaf.clone(); copy.transform(xf, scaling); + keep_listed(&mut copy, kept); // Each copy's own geometry effects make a different amount, as the copy and its effects scale. let mut cost = Budget::of(&moved).plus(effects_cost(©, &moved)); // Each copy carries the art of its linked masks, moved with it. @@ -1584,9 +1590,12 @@ fn item_makes_piece(item: &AppearanceItem) -> bool { /// effects. In paint order, each such fill or stroke is a path of its own: the leaf's geometry after /// the geometry effects of that fill or stroke before its first such Transform effect, transformed /// as [`transform_leaf`] transforms an object with that effect's map, copies and options, so the -/// stroke's weight and dashes, the distances of its remaining effects and its pattern follow the -/// map. The reference box is that geometry's bounds and the seed is the leaf's id, as the renderer -/// draws a fill's or stroke's own effects. The geometry effects after the Transform effect then +/// stroke's weight and dashes, the distances of the effects before the Transform effect and its +/// pattern follow the map, and the raster effects before it that follow it take their followed +/// values ([`follow_piece`]). The effects after it keep their values and apply to what it made +/// ([`keep_listed`]), and with Transform Objects off so do the effects before it. The reference box +/// is that geometry's bounds and the seed is the leaf's id, as the renderer draws a fill's or +/// stroke's own effects. The geometry effects after the Transform effect then /// apply to the transformed path and its copies as one piece ([`transform_piece`]), and its other /// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's /// geometry between them, with their own effects. `max` is the budget of the whole art and `used` @@ -1659,9 +1668,16 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let params = merged_params(&fx[at].id, &fx[at].params); // Where each effect the piece keeps sits among the item's effects. let kept: Vec = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect(); + // A Transform effect after this one that makes copies or moves at random holds the raster + // effects before this one back from every Transform effect ([`crate::raster::followed`]). + let held_later = fx.get(at + 1..).unwrap_or_default().iter().any(crate::raster::holds_back); + // The effects before the Transform effect that it keeps, each raster effect following the + // geometry effects before it ([`crate::raster::without_geometry`]) unless held back. + let kept_before: Vec = + if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(before) }; if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); *it.effects_mut() = rest; let mut one = Appearance { items: vec![it.clone()], ..Default::default() }; if carry.moves(&one) { @@ -1683,7 +1699,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt group.push(Arc::new(p)); } let mut p = Node::group(leaf.id, group); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -1691,16 +1707,22 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); - *it.effects_mut() = rest; + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); + *it.effects_mut() = rest.clone(); let mut p = piece(vec![it]); if carry.moves(&p.appearance) { carry.apply(&mut p.appearance); } set_geometry(&mut p, g.clone(), rule); - let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); + let after = kept_before.len(); + let keep = |k: usize| k >= after || !options.objects; + let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, Some((&rest, &keep))); *used = used.plus(made); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + let from = crate::raster::held_back(before); + if moved && !held_later { + follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]); + } + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -1717,27 +1739,45 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece: /// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's /// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of -/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with -/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. +/// the effect, at the effect's own distances. A Transform effect with Transform Patterns or Scale +/// Strokes & Effects on transforms each path as [`transform_leaf`] does, and leaves the values of +/// the effects after it, and with Transform Objects off those before it, as they are +/// ([`keep_listed`]). /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path -/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and -/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art -/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source, -/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the -/// piece was made from, and the index among that item's effects of each effect the piece kept, -/// which a deferred live-effect plug-in is listed by. -fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect +/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before +/// the piece's effects that makes copies or moves at random holds back, and the effects before +/// such a Transform effect among the piece's effects. The geometry effects then +/// leave the fills and strokes; their other effects stay. The paths keep to `max` together with +/// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in +/// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the +/// fill or stroke the piece was made from, and the index among that item's effects of each effect +/// the piece kept, which a deferred live-effect plug-in is listed by. +fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { count = count.max(item.effects().len()); } }); + // The effects before a Transform effect among the piece's effects that makes copies or moves at + // random are held back from the Transform effects before it too. + let mut held = held; + for_each_leaf(p, &mut |l| { + if let Some(last) = l.appearance.items.first().and_then(|item| item.effects().iter().rposition(crate::raster::holds_back)) { + held = held.max(last); + } + }); for j in 0..count { let Some(bounds) = p.geometric_bounds() else { break }; + let from = held.min(j); + let mut holds = false; for_each_leaf(p, &mut |leaf| { let Some(item) = leaf.appearance.items.first().cloned() else { return }; let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return }; + holds |= crate::raster::holds_back(&e); + // The effects before this one as they were before it moved the path. + let was = item.effects().get(from..j).unwrap_or_default(); let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. let before = Budget::of(&path); @@ -1754,8 +1794,12 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let (made, _) = transform_leaf(leaf, &path, map, copies, options, room); + let keep = |k: usize| k > j || (k < j && !options.objects); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, Some((item.effects(), &keep))); *used = used.minus(before).plus(made); + if moved { + follow_piece(leaf, was, from, &e); + } return; } let appearance = leaf.appearance.clone(); @@ -1772,7 +1816,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m carry.apply(&mut leaf.appearance); } set_geometry(leaf, out, rule); + follow_piece(leaf, was, from, &e); }); + if holds { + held = held.max(j); + } } for_each_leaf(p, &mut |leaf| { for item in &mut leaf.appearance.items { @@ -1781,6 +1829,53 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m }); } +/// The effects a Transform effect in a fill's or stroke's own list leaves as they were +/// ([`keep_listed`]): the list as it was before the Transform effect was applied, and which of its +/// indices keep their values. `None` for an object's own Transform effect. +type Kept<'a> = Option<(&'a [Effect], &'a dyn Fn(usize) -> bool)>; + +/// Put back on the fills and strokes of each path under `p` the effect `kept`'s list holds at each +/// index it takes, in place of what a Transform effect in their own list scaled or moved with its +/// options ([`transform_leaf`]). The effects after the Transform effect apply to the art it made, +/// and with Transform Objects off the effects before it keep their values too. A piece's fills and +/// strokes share one list: a live-effect plug-in that paints what it returns gives each paint a copy +/// of the fill or stroke ([`vectorcraft_plugins::painted::items`]). +fn keep_listed(p: &mut Node, kept: Kept) { + let Some((was, keep)) = kept else { return }; + for_each_leaf(p, &mut |leaf| { + for item in &mut leaf.appearance.items { + for (k, (e, old)) in item.effects_mut().iter_mut().zip(was).enumerate() { + if keep(k) { + e.clone_from(old); + } + } + } + }); +} + +/// Make the raster effects of each of `leaf`'s fills and strokes from index `from` on follow +/// Transform effect `t`, listed after them, once `t` has moved the leaf +/// ([`crate::raster::follow_one`]). The fills and strokes are copies of one fill or stroke (those a +/// painted plug-in returns, [`vectorcraft_plugins::painted::items`]), with the same effects. `was` +/// holds those effects of the first one as they were before `t` was applied: each one that +/// follows takes the value [`crate::raster::followed`] gives it, in place of what Scale Strokes & +/// Effects made of it, and the others keep theirs. +fn follow_piece(leaf: &mut Node, was: &[Effect], from: usize, t: &Effect) { + let mut follow = was.to_vec(); + if !crate::raster::follow_one(&mut follow, t) { + return; + } + for item in &mut leaf.appearance.items { + for (k, (new, old)) in follow.iter().zip(was).enumerate() { + if new != old + && let Some(e) = item.effects_mut().get_mut(from + k) + { + *e = new.clone(); + } + } + } +} + /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in /// order. A group inside one of them is part of it. fn type_outline_bounds(n: &Node, out: &mut Vec>) { diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index ec9563f05c..20a66e9a83 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,7 +8,9 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, L use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds}; +use crate::solids::{ + Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, +}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -558,17 +560,11 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); + remaining.retain(|e| !is_revolve(&e.id)); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining.retain(|e| !is_geometry(&e.id)); + remaining = crate::without_geometry(&remaining); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { @@ -835,8 +831,7 @@ fn group_art(n: &Node, expand: bool) -> Option { // One shadow for every member's surface, behind them all. let mut children = children; children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 538d2a5028..a49829f50c 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -978,17 +978,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; children.extend(shadow.into_iter().chain(surfaces)); // Later geometry effects act on the projected art as a whole. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + remaining = crate::without_geometry(&n.appearance.effects); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in &paints { @@ -1024,14 +1017,28 @@ fn group_solid_art(n: &Node) -> Option { reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let (shadow, surfaces) = art_for(n, fx, &shapes)?; let children = shadow.into_iter().chain(surfaces).collect(); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); Some(art) } +/// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the +/// art it projects. Each raster effect before it follows the Transform effects among those +/// ([`crate::without_geometry`]), unless a Transform effect after it makes copies or moves at +/// random ([`crate::raster::holds_back`]); the effects after it stay as they are. +pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec { + let (before, after) = effects.split_at(index.min(effects.len())); + let after = after.get(1..).unwrap_or_default(); + let kept: Vec = if after.iter().any(crate::raster::holds_back) { + before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() + } else { + crate::without_geometry(before) + }; + kept.into_iter().chain(after.iter().cloned()).collect() +} + /// One solid of a 3D object as a mesh, with the color it is painted. #[derive(Clone, Debug, PartialEq)] pub struct SolidMesh { diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 5246a639e8..88300586b3 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod rasterfollow; mod scale; mod transformfx; diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs new file mode 100644 index 0000000000..c674cf5041 --- /dev/null +++ b/crates/effects/src/tests/rasterfollow.rs @@ -0,0 +1,421 @@ +//! Raster effects listed before a Transform effect follow it: the Transform effect transforms +//! what they paint. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::{RasterFx, bake_document, expand_outlined, raster_effects}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn shadow(x: f64, y: f64, blur: f64) -> Effect { + fx("stylize.dropShadow", json!({"x": x, "y": y, "blur": blur})) +} + +fn transform(params: Value) -> Effect { + fx("distort.transform", params) +} + +/// The offset and blur of the first Drop Shadow `effects` draw. +fn drawn_shadow(effects: &[Effect]) -> (f64, f64, f64) { + match raster_effects(effects).first() { + Some(RasterFx::DropShadow { dx, dy, blur, .. }) => (*dx, *dy, *blur), + other => panic!("expected a drop shadow, got {other:?}"), + } +} + +fn close(a: (f64, f64, f64), b: (f64, f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 && (a.2 - b.2).abs() < 1e-9 +} + +/// A 50 pt red square with `effects`. +fn square(id: u64, effects: Vec) -> Node { + let mut n = Node::path( + NodeId(id), + shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + n.appearance.effects = effects; + n +} + +#[test] +fn a_transform_after_a_drop_shadow_scales_its_offset_and_blur() { + for strokes in [false, true] { + let effects = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200, "strokes": strokes}))]; + assert!(close(drawn_shadow(&effects), (20.0, 20.0, 8.0)), "Scale Strokes & Effects {strokes}: {:?}", drawn_shadow(&effects)); + } +} + +#[test] +fn a_drop_shadow_after_a_transform_keeps_its_offset() { + let effects = [transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 10.0, 4.0)]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +#[test] +fn the_offset_follows_an_uneven_scale_a_rotation_and_a_reflection() { + let uneven = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 100}))]; + assert!(close(drawn_shadow(&uneven), (20.0, 10.0, 4.0 * 2f64.sqrt())), "{:?}", drawn_shadow(&uneven)); + // A turn of 90° counterclockwise on screen takes an offset to the right to one upward. + let turned = [shadow(10.0, 0.0, 4.0), transform(json!({"rotate": 90}))]; + let (dx, dy, blur) = drawn_shadow(&turned); + assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9 && (blur - 4.0).abs() < 1e-9, "{dx} {dy} {blur}"); + let reflected = [shadow(10.0, 0.0, 4.0), transform(json!({"reflectX": true}))]; + assert!(close(drawn_shadow(&reflected), (-10.0, 0.0, 4.0)), "{:?}", drawn_shadow(&reflected)); +} + +#[test] +fn transforms_after_the_shadow_apply_in_order() { + let effects = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"rotate": 90}))]; + let (dx, dy, _) = drawn_shadow(&effects); + assert!(dx.abs() < 1e-9 && (dy + 20.0).abs() < 1e-9, "{dx} {dy}"); + // Only the Transform after a shadow moves it. + let between = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), shadow(0.0, 10.0, 0.0)]; + let drawn = raster_effects(&between); + let offsets: Vec<(f64, f64)> = drawn + .iter() + .filter_map(|r| match r { + RasterFx::DropShadow { dx, dy, .. } => Some((*dx, *dy)), + _ => None, + }) + .collect(); + assert_eq!(offsets, vec![(20.0, 0.0), (0.0, 10.0)]); +} + +#[test] +fn transforms_that_leave_the_art_or_copy_it_leave_the_shadow() { + for params in [ + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "copies": 1}), + json!({"scaleH": 200, "scaleV": 200, "random": true}), + ] { + let effects = [shadow(10.0, 10.0, 4.0), transform(params.clone())]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{params}: {:?}", drawn_shadow(&effects)); + } + let mut hidden = transform(json!({"scaleH": 200, "scaleV": 200})); + hidden.visible = false; + let effects = [shadow(10.0, 10.0, 4.0), hidden]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +/// A hidden shadow kept when the Transform effect is applied (Expand Appearance) draws as it would +/// have before, once it is shown. +#[test] +fn a_hidden_shadow_follows_the_transform_it_loses() { + let mut hidden = shadow(10.0, 10.0, 4.0); + hidden.visible = false; + let kept = crate::raster::without_geometry(&[hidden, transform(json!({"scaleH": 200, "scaleV": 200}))]); + assert_eq!(kept.len(), 1); + let mut shown = kept[0].clone(); + shown.visible = true; + assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0))); +} + +/// Rasterize keeps its room around the art: its image keeps its own pixel grid. +#[test] +fn rasterize_before_a_transform_keeps_its_options() { + let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6})); + let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::raster::followed(&effects)[0], rasterize); + assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); +} + +/// An SVG filter keeps its options: it paints in its own user space. +#[test] +fn an_svg_filter_before_a_transform_keeps_its_options() { + let filter = fx(crate::SVG_FILTER, json!({"origin": [10, 10]})); + let effects = [filter.clone(), transform(json!({"scaleH": 200, "scaleV": 200, "rotate": 90}))]; + assert_eq!(crate::raster::followed(&effects)[0], filter); + assert_eq!(crate::raster::without_geometry(&effects), vec![filter]); +} + +/// The offset of every Drop Shadow drawn on `n` and the nodes under it, from the object's effects +/// and from each fill's and stroke's. +fn shadows_in(n: &Node) -> Vec<(f64, f64)> { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + for list in lists { + for r in raster_effects(list) { + if let RasterFx::DropShadow { dx, dy, .. } = r { + out.push((dx, dy)); + } + } + } + }); + out +} + +fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9) +} + +/// A square whose fill carries `effects` of its own. +fn fill_square(id: u64, effects: Vec) -> Node { + let mut n = square(id, vec![]); + if let Some(item) = n.appearance.items.first_mut() { + *item.effects_mut() = effects; + } + n +} + +/// A fill's own Transform effect transforms the fill's raster effects before it: drawn as they are +/// (no options), drawn through the art the Transform Patterns or Scale Strokes & Effects option +/// makes, expanded and baked. Scale Strokes & Effects does not scale a shadow that follows the +/// Transform effect a second time. +#[test] +fn a_fills_shadow_follows_the_fills_own_transform() { + let cases = [ + (json!({"rotate": 90}), (0.0, -10.0)), + (json!({"rotate": 90, "strokes": true}), (0.0, -10.0)), + (json!({"scaleH": 200, "scaleV": 200, "strokes": true}), (20.0, 0.0)), + (json!({"scaleH": 200, "scaleV": 200, "patterns": true}), (20.0, 0.0)), + (json!({"reflectX": true, "patterns": true}), (-10.0, 0.0)), + ]; + for (params, want) in cases { + let n = fill_square(11, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]); + // Drawn and expanded through its art when an option makes some. + if crate::has_transform_art(&n) { + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[want]), "{params}: drawn {:?}", shadows_in(&art)); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(near(&shadows_in(&expanded), &[want]), "{params}: expanded {:?}", shadows_in(&expanded)); + } else { + assert!(near(&shadows_in(&n), &[want]), "{params}: drawn {:?}", shadows_in(&n)); + } + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = baked.node(NodeId(11)).unwrap(); + assert!(near(&shadows_in(b), &[want]), "{params}: baked {:?}", shadows_in(b)); + } +} + +/// A Transform effect after a fill's own Transform effect with Scale Strokes & Effects on turns the +/// shadow between them, which the first one leaves as it is; a Transform effect that moves at random +/// holds the shadow before it back from the Transform effects after it. +#[test] +fn a_fills_later_transform_turns_the_shadow_after_its_first() { + let n = fill_square( + 12, + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90}))], + ); + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[(0.0, -10.0)]), "{:?}", shadows_in(&art)); + let held = fill_square( + 13, + vec![ + shadow(10.0, 0.0, 0.0), + transform(json!({"scaleH": 200, "scaleV": 200, "random": true, "strokes": true})), + transform(json!({"rotate": 90})), + ], + ); + let art = crate::reshape(None, &held, None).unwrap(); + let drawn = shadows_in(&art); + assert!(drawn.len() == 1 && drawn[0].0 > 0.0 && drawn[0].1.abs() < 1e-9, "{drawn:?}"); +} + +/// A fill's own Transform effect with Transform Patterns or Scale Strokes & Effects on leaves the +/// fill's raster effects after it as they are: they paint around the transformed art, its copies +/// included. With Transform Objects off it leaves the raster effects before it as they are too. +/// Drawn, expanded and baked alike. +#[test] +fn a_fills_own_transform_leaves_the_shadow_after_it() { + let cases = [ + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 120, "copies": 1, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}))], + // A later Transform effect with an option leaves the shadow after it as well. + vec![ + transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})), + transform(json!({"scaleH": 150, "scaleV": 150, "strokes": true})), + shadow(10.0, 0.0, 0.0), + ], + ]; + for effects in cases { + let n = fill_square(18, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{effects:?}: drawn {drawn:?}"); + let expanded = shadows_in(&expand_outlined(None, &n, None).unwrap()); + assert!(!expanded.is_empty() && near(&expanded, &vec![(10.0, 0.0); expanded.len()]), "{effects:?}: expanded {expanded:?}"); + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = shadows_in(baked.node(NodeId(18)).unwrap()); + assert!(!b.is_empty() && near(&b, &vec![(10.0, 0.0); b.len()]), "{effects:?}: baked {b:?}"); + } +} + +/// A geometry effect after a fill's own Transform effect with Scale Strokes & Effects on reshapes +/// the transformed art by its own distance, as it does after an object's Transform effect. +#[test] +fn a_fills_own_transform_leaves_the_offset_after_it() { + let offset = fx("path.offsetPath", json!({"offset": 5})); + let scaled = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = fill_square(19, vec![scaled.clone(), offset.clone()]); + let on_fill = crate::reshape(None, &n, None).unwrap(); + let on_object = crate::reshape(None, &square(20, vec![scaled, offset]), None).unwrap(); + let (fill, object) = (on_fill.geometric_bounds().unwrap(), on_object.geometric_bounds().unwrap()); + // The 50 pt square scaled to 100 pt about its center, then offset by 5 pt on each side. + assert!((fill.width() - 110.0).abs() < 1e-6 && (fill.height() - 110.0).abs() < 1e-6, "{fill:?}"); + assert!((fill.width() - object.width()).abs() < 1e-6 && (fill.x0 - object.x0).abs() < 1e-6, "{fill:?} {object:?}"); + let expanded = expand_outlined(None, &n, None).unwrap().geometric_bounds().unwrap(); + assert!((expanded.width() - 110.0).abs() < 1e-6 && (expanded.x0 - fill.x0).abs() < 1e-6, "expanded {expanded:?}"); +} + +/// The SVG filters on `n` and the nodes under it, from the object's effects and from each fill's +/// and stroke's. +fn filters_in(n: &Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + out.extend(lists.flatten().filter(|e| e.id == crate::SVG_FILTER).cloned()); + }); + out +} + +/// An SVG filter after a fill's own Transform effect with Scale Strokes & Effects on keeps its +/// scale and the origin of its user space, as it does after the same Transform effect without +/// options and after an object's own Transform effect. +#[test] +fn a_fills_own_transform_leaves_the_svg_filter_after_it() { + let filter = fx(crate::SVG_FILTER, json!({"scale": 1.0, "origin": [10.0, 10.0]})); + for params in [ + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "patterns": true}), + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + ] { + let art = crate::reshape(None, &fill_square(21, vec![transform(params.clone()), filter.clone()]), None).unwrap(); + let drawn = filters_in(&art); + assert!(!drawn.is_empty() && drawn.iter().all(|e| *e == filter), "{params}: {drawn:?}"); + } + let object = square(22, vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true})), filter.clone()]); + let art = crate::reshape(None, &object, None).unwrap(); + assert!(!filters_in(&art).is_empty() && filters_in(&art).iter().all(|e| *e == filter), "{:?}", filters_in(&art)); +} + +/// The 3D effects keep the shadow before a Transform effect that shapes the art they project, the +/// object's and its fill's 3D effect alike. +#[test] +fn a_shadow_before_a_transform_follows_it_into_3d_art() { + let before = vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let mut on_object = before.clone(); + on_object.push(fx(three_d, json!({}))); + let projected = art(&square(14, on_object)).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the object: {:?}", shadows_in(&projected)); + let mut on_fill = square(15, before.clone()); + if let Some(item) = on_fill.appearance.items.first_mut() { + *item.effects_mut() = vec![fx(three_d, json!({}))]; + } + let projected = art(&on_fill).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the fill: {:?}", shadows_in(&projected)); + } +} + +#[test] +fn glows_blurs_and_feathers_scale_with_the_transform_after_them() { + let effects = [ + fx("stylize.outerGlow", json!({"blur": 5})), + fx("stylize.innerGlow", json!({"blur": 5})), + fx("stylize.feather", json!({"radius": 5})), + fx("blur.gaussian", json!({"radius": 5})), + transform(json!({"scaleH": 50, "scaleV": 50})), + ]; + let sizes: Vec = raster_effects(&effects) + .iter() + .map(|r| match r { + RasterFx::OuterGlow { blur, .. } | RasterFx::InnerGlow { blur, .. } => *blur, + RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } => *radius, + other => panic!("unexpected {other:?}"), + }) + .collect(); + assert_eq!(sizes, vec![2.5; 4]); + // How far the shadow paints past the object follows too. + let shadowed = [shadow(10.0, 10.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::outset(&shadowed, Rect::new(0.0, 0.0, 100.0, 100.0)), 20.0); +} + +#[test] +fn pixel_filter_sizes_scale_with_the_transform_after_them() { + let effects = [ + fx("artistic.coloredPencil", json!({"pencilWidth": 5})), + fx("artistic.paintDaubs", json!({"brushSize": 10})), + transform(json!({"scaleH": 200, "scaleV": 200})), + ]; + let drawn = raster_effects(&effects); + assert!(matches!(drawn.first(), Some(RasterFx::Pixel(crate::PixelFx::ColoredPencil { width, .. })) if (width - 10.0).abs() < 1e-9), "{drawn:?}"); + assert!(matches!(drawn.get(1), Some(RasterFx::Pixel(crate::PixelFx::PaintDaubs { size, .. })) if (size - 20.0).abs() < 1e-9), "{drawn:?}"); +} + +/// A Transform effect after a 3D effect that makes copies or moves at random holds back the shadow +/// before the 3D effect, as it does without the 3D effect. +#[test] +fn a_copying_transform_after_3d_holds_the_shadow_back() { + for later in [json!({"copies": 1}), json!({"scaleH": 150, "scaleV": 150, "random": true})] { + let plain = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(later.clone())]; + assert!(close(drawn_shadow(&plain), (10.0, 0.0, 0.0)), "{later}: {:?}", drawn_shadow(&plain)); + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let effects = vec![plain[0].clone(), plain[1].clone(), fx(three_d, json!({})), plain[2].clone()]; + let projected = art(&square(16, effects)).unwrap(); + let drawn = shadows_in(&projected); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{three_d} {later}: {drawn:?}"); + } + } +} + +/// In a fill's own effects, a Transform effect that makes copies after a Transform effect with +/// Transform Patterns on holds back the shadow before both, and before a Transform effect between +/// them. +#[test] +fn a_fills_copying_transform_holds_back_the_shadow_before_an_earlier_transform() { + let patterns = transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})); + let copies = transform(json!({"copies": 1})); + let lists = [ + vec![shadow(10.0, 0.0, 0.0), patterns.clone(), copies.clone()], + vec![shadow(10.0, 0.0, 0.0), patterns, transform(json!({"rotate": 90})), copies], + ]; + for effects in lists { + let n = fill_square(17, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{} effects: {drawn:?}", effects.len()); + } +} + +#[test] +fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() { + let effects = vec![shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + // A path drawn through its art (Scale Strokes & Effects on), expanded. + let mut with_options = effects.clone(); + with_options[1] = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = square(7, with_options); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(close(drawn_shadow(&expanded.appearance.effects), (20.0, 20.0, 8.0)), "{:?}", expanded.appearance.effects); + // A path and a group, baked for export. + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, square(8, effects.clone())).unwrap(); + let mut g = Node::group(NodeId(9), vec![std::sync::Arc::new(square(10, vec![]))]); + g.appearance.effects = effects.clone(); + d.insert(Some(layer), 1, g).unwrap(); + let baked = bake_document(&d).unwrap(); + for id in [8, 9] { + let b = baked.node(NodeId(id)).unwrap(); + assert!(!b.appearance.effects.iter().any(|e| e.id == "distort.transform"), "{id}: {:?}", b.appearance.effects); + assert!(close(drawn_shadow(&b.appearance.effects), (20.0, 20.0, 8.0)), "{id}: {:?}", b.appearance.effects); + } +} diff --git a/crates/effects/src/tests/transformfx.rs b/crates/effects/src/tests/transformfx.rs index de69116b79..1046f3c437 100644 --- a/crates/effects/src/tests/transformfx.rs +++ b/crates/effects/src/tests/transformfx.rs @@ -222,7 +222,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() { } #[test] -fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects() { +fn a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects() { let mut n = stroke_with_effect(json!({})); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { f.effects.push(transform(json!({"scaleH": 300, "scaleV": 300, "strokes": true}))); @@ -231,10 +231,10 @@ fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects let art = reshape(None, &n, None).unwrap(); let fill = leaves(&art).into_iter().find(|p| p.appearance.stroke().is_none()).unwrap(); let AppearanceItem::Fill(f) = &fill.appearance.items[0] else { panic!("not a fill") }; - // The Offset Path, scaled from 5 to 15, applies to the transformed fill's path. + // The Offset Path applies to the transformed fill's path at its own 5 pt. assert!(f.effects.is_empty(), "{:?}", f.effects); let w = fill.geometric_bounds().unwrap().width(); - assert!(close(w, 330.0), "{w}"); + assert!(close(w, 310.0), "{w}"); } #[test] diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index b2a9d74f83..bfe3d2f4ef 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -20,6 +20,7 @@ use vectorcraft_geom::Affine; use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; use super::super::menucmds::has_image; +use super::super::paint::{PROCESS_ON_TRANSPARENCY, gray_colorized_with_process}; use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; /// Remove Background's command. @@ -39,7 +40,7 @@ pub fn specs() -> Vec { "Remove Background…", ["Object", "Plug-ins", "Image Tools"], None, - "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a result with no pixel left, and a grayscale image colorized with a process color (which can't colorize gray pixels with transparency) are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", has_image, remove_background )] @@ -182,8 +183,14 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De // Every image is checked before any is decoded. let mut images = vec![]; for id in ids { - let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; + let Some(n) = st.doc.node(id) else { continue }; + let NodeKind::Image(im) = &n.kind else { continue }; let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; + // The result has an alpha channel, which a grayscale image colorized with a process color + // can't take. + if gray_colorized_with_process(&st.doc, n).is_some() { + return Err(bad(C, PROCESS_ON_TRANSPARENCY)); + } // The preview stands in for a file that hasn't been read, or that is kept at its preview. if im.link.is_some() && blob.is_proxy() { return Err(bad(C, "the linked image shows its preview: embed or relink it first")); diff --git a/crates/engine/src/cmd/addons/image/tests.rs b/crates/engine/src/cmd/addons/image/tests.rs index df27180e82..d57f58046f 100644 --- a/crates/engine/src/cmd/addons/image/tests.rs +++ b/crates/engine/src/cmd/addons/image/tests.rs @@ -1,8 +1,9 @@ use std::io::Cursor; use std::sync::Arc; -use image::{DynamicImage, ImageBuffer, Luma, Rgb}; +use image::{DynamicImage, ImageBuffer, Luma, LumaA, Rgb}; use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; use vectorcraft_geom::Affine; @@ -333,6 +334,54 @@ fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() { assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); } +/// A process color can't colorize a grayscale image with transparency, so a gray image colorized +/// with one keeps its pixels. Black, white and spot colors can, so those images are cleared. +#[test] +fn a_gray_image_colorized_with_a_process_color_is_refused() { + let gray = || { + png(DynamicImage::ImageLuma8(ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }])))) + }; + let (mut s, id) = open(&gray()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the fill colorizes the image"); + let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "the document and its undo steps are unchanged"); + // Another image that already has an alpha channel and a process colorize color, as an opened + // file can hold, neither lets this one through nor stops a run on an image that can be cleared. + let held = ImageBlob::png(png(DynamicImage::ImageLumaA8(ImageBuffer::from_pixel(2, 2, LumaA([128u8, 100]))))); + let other = place_linked(&mut s, "held", held, (2, 2)); + s.edit("Embed and colorize", |d, _| { + let n = d.node_mut(other).unwrap(); + n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + if let NodeKind::Image(im) = &mut n.kind { + im.link = None; + } + Ok(()) + }) + .unwrap(); + let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let e = s.execute(C, &json!({"ids": [id.0]})).unwrap_err(); + assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "with another image refused already, nothing changes"); + s.execute("paint.setFill", &json!({"color": "#000000", "ids": [id.0]})).unwrap(); + assert_eq!(s.execute(C, &json!({"ids": [id.0]})).unwrap()["changed"], 1, "an image colorized with black is cleared beside the other"); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!(out.get_pixel(0, 0).0[1], 0, "the background is cleared"); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the image stays colorized"); + for fill in [json!({"color": "#000000"}), json!({"color": "#ffffff"}), json!({"swatch": "Ink"})] { + let (mut s, id) = open(&gray()); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} colorizes the image"); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!(out.get_pixel(0, 0).0[1], 0, "{fill}: the background is cleared"); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} still colorizes the image"); + } +} + /// The ICC profile the colors are in stays with them. #[test] fn the_color_profile_is_kept() { diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 53e1b655a1..94fe55c119 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 9ec21b6541..1046c4a948 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -210,8 +210,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result { let remembered = s.store_profile(&info.name, &bytes); // A new file of a working space: the active settings still hold the old one. let working = cms::active_settings(); - if working.rgb == info.name || working.cmyk == info.name { + let is_working = working.rgb == info.name || working.cmyk == info.name; + if is_working { cms::set_active(&working).map_err(|e| bad(C, e.to_string()))?; + } + // A new file of a working space, or of the CMYK profile View → Proof Setup targets: redraw. + if is_working || proof_targets(&info.name) { touch_all(s); } Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered})) @@ -233,10 +237,17 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("no profile named `{name}`"))); } s.unstore_profile(&info.name); - touch_all(s); + if proof_targets(&info.name) { + touch_all(s); + } Ok(json!({"removed": info.name})) } +/// Whether View → Proof Setup targets the CMYK profile `name`. +fn proof_targets(name: &str) -> bool { + matches!(proof::view().setup.target, ProofTarget::Cmyk(t) if t == name) +} + #[cfg(target_arch = "wasm32")] fn load_profile(_: &mut Session, _: &Value) -> Result { Err(bad("color.loadProfile", "loading profiles from disk isn't available in the browser")) @@ -763,17 +774,33 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result { if let Some(on) = on_param(p, "proof", C)? { v.proof_colors = on; } - proof::set_view(v.clone()); - touch_all(s); + store_view(s, v.clone()); Ok(view_json(&v)) } +/// Make `v` the proof view and redraw every document. When `v` equals the current view, the view +/// and the revisions stay as they are; Separations Preview's visible plates are compared as a set. +fn store_view(s: &mut Session, v: proof::ProofView) { + let shown = |v: &proof::ProofView| proof::ProofView { + separations: v.separations.as_ref().map(|vis| { + let mut vis = vis.clone(); + vis.sort(); + vis.dedup(); + vis + }), + ..v.clone() + }; + if shown(&v) != shown(&proof::view()) { + proof::set_view(v); + touch_all(s); + } +} + fn toggle_view(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut proof::ProofView, Option)) -> Result { let on = on_param(p, "on", cmd)?; let mut v = proof::view(); f(&mut v, on); - proof::set_view(v.clone()); - touch_all(s); + store_view(s, v.clone()); Ok(view_json(&v)) } @@ -805,7 +832,15 @@ fn separations(s: &mut Session, p: &Value) -> Result { match p.get("plates") { None | Some(Value::Null) => {} Some(Value::Array(a)) if a.iter().all(Value::is_string) => { - v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::>>()?); + let mut vis: Vec = Vec::new(); + for n in a.iter().filter_map(Value::as_str) { + let n = check(n)?; + // A plate named twice is shown once. + if !vis.contains(&n) { + vis.push(n); + } + } + v.separations = Some(vis); } Some(_) => { let key = "plates"; @@ -824,8 +859,7 @@ fn separations(s: &mut Session, p: &Value) -> Result { vis.push(n); } } - proof::set_view(v); - touch_all(s); + store_view(s, v); Ok(plates_json(s)) } diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index ed83a01410..dd6af8e6eb 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; -use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, named_ids, targets}; pub fn specs() -> Vec { vec![ @@ -21,13 +21,13 @@ pub fn specs() -> Vec { has_doc, selection ), - cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document", has_doc, generate), + cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document; ids and id are not used, and a malformed one is an error", has_doc, generate), cmd!( "css.export", "Export CSS", [], None, - "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", + "{path?: string, scope?: selection|all (default selection when left out; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} a path or scope given as anything but a string, null included, is an error, and so is a malformed ids or id with scope all, which does not use them; write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", has_doc, export ), @@ -77,16 +77,34 @@ fn selection(s: &mut Session, p: &Value) -> Result { Ok(result(&sheet(s, p, ids, "css.selection")?)) } +/// Checks the form of the `ids` or `id` that `p` gives a command that exports every object and does +/// not use them: a malformed one is an error, as it is for the selection. +fn unused_ids(p: &Value) -> Result<()> { + named_ids(p).map(|_| ()) +} + fn generate(s: &mut Session, p: &Value) -> Result { + unused_ids(p)?; let ids = everything(s)?; Ok(result(&sheet(s, p, ids, "css.generate")?)) } +/// The string `p` gives for `key`, or `None` when `p` leaves it out. Any value given that is not a +/// string, null included, is an error: "`{key}` must be a string". +fn given_text<'a>(p: &'a Value, key: &str, cmd: &str) -> Result> { + p.get(key).map(|v| v.as_str().ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose() +} + fn export(s: &mut Session, p: &Value) -> Result { const C: &str = "css.export"; - let ids = match str_param(p, "scope").unwrap_or("selection") { + let scope = given_text(p, "scope", C)?; + let path = given_text(p, "path", C)?; + let ids = match scope.unwrap_or("selection") { "selection" => targets(s, p)?, - "all" => everything(s)?, + "all" => { + unused_ids(p)?; + everything(s)? + } other => return Err(bad(C, format!("scope must be selection or all (got {other})"))), }; let sheet = sheet(s, p, ids, C)?; @@ -115,7 +133,7 @@ fn export(s: &mut Session, p: &Value) -> Result { } } let n = sheet.rules.len(); - match str_param(p, "path") { + match path { Some(path) => { write_file(path, css.as_bytes())?; let folder = std::path::Path::new(path).parent(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b7affffa81..157049aa7c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -38,7 +38,7 @@ pub fn specs() -> Vec { has_doc, doc_move ), - cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), + cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find), cmd!( @@ -50,7 +50,11 @@ pub fn specs() -> Vec { has_doc, doc_compare ), - cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so is a data set's image value outside them; a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| { + let st = s.doc()?; + let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st)); + Ok(serde_json::to_value(&*d).unwrap_or(Value::Null)) + }), cmd!( "document.setUnits", "Units", diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 5737235963..4e7e1fa658 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -546,12 +546,14 @@ fn expand_piece_leaves( } /// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its -/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no +/// path (a plain path then), and every geometry effect of its own removed, with each raster effect +/// following the Transform effects after it +/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no /// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their /// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a /// path that one paints goes through [`expand_pieces`] instead. pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, without_geometry}; if !has_geometry(&l.appearance.effects) { return None; } @@ -562,7 +564,7 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = without_geometry(&m.appearance.effects); Some(m) } @@ -604,9 +606,10 @@ fn with_path(l: &Node, path: PathData) -> Node { } /// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which -/// shape the outline it paints ([`fill_shape`]). +/// shape the outline it paints ([`fill_shape`]), with each raster effect following the Transform +/// effects after it ([`without_geometry`](vectorcraft_render::effects::without_geometry)). fn fill_effects(f: &FillLayer) -> Vec { - f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect() + vectorcraft_render::effects::without_geometry(&f.effects) } /// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]). diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 7f95b8e203..fe7c9d8f3e 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 8c9876054a..6f0f8fd533 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -398,7 +398,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); // Relative links are found in the SVG's folder (when `name` is a path). let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f)); - restore_or_import(editing, || import_svg(&text, folder.as_deref()))? + restore_or_import(editing, || open_svg(&text, folder.as_deref()))? } "pdf" | "ai" | "ait" => { // The file is read and checked once, for the document it carries, its pages, the @@ -573,6 +573,33 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result /// own): rasters stay linked, SVG files become art, missing ones placeholders (see /// [`vectorcraft_svg::ImportOptions`]). pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + import_svg_finding(text, folder, &std::collections::HashSet::new()).map(|(doc, warnings, _)| (doc, warnings)) +} + +/// [`import_svg`] for a document opened from an SVG: with the variable data the file carries (SVG +/// Options › Include Variable Data), read as [`crate::cmd::variablecmds::svg_library`] says. Variable +/// data that can't be read is left out with a warning; the art opens either way. +fn open_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + let lib = match crate::cmd::variablecmds::svg_library(text) { + None => return import_svg(text, folder), + Some(Err(e)) => { + let (doc, mut warnings) = import_svg(text, folder)?; + warnings.push(format!("the file's variable data wasn't read: {e}")); + return Ok((doc, warnings)); + } + Some(Ok(lib)) => lib, + }; + let (mut doc, mut warnings, found) = import_svg_finding(text, folder, &lib.elements())?; + warnings.extend(crate::cmd::variablecmds::give_svg_library(&mut doc, lib, &found)); + Ok((doc, warnings)) +} + +/// [`import_svg`], also returning the object made of each element whose id `ids` lists. +fn import_svg_finding( + text: &str, + folder: Option<&str>, + ids: &std::collections::HashSet, +) -> Result<(Document, Vec, vectorcraft_svg::Found)> { // The importer reads on a thread of its own (for its stack): what may be read is what may be // read here. let scope = crate::file_access::ScopeHandle::capture(); @@ -588,7 +615,7 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Some((bytes, link)) }) }; - vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err) + vectorcraft_svg::import_finding(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }, ids).map_err(err) } /// Open a file's bytes as the new active document (what `document.open` does) → diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 3123fdcfb1..eebac553d3 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -90,6 +90,12 @@ pub const OPTIONS: &[FormatOption] = &[ default: "false", description: "embed the fonts type uses as @font-face, subset to the characters used (whole when the font's licence forbids subsetting, left out with a warning when it forbids embedding)", }, + FormatOption { + name: "variableData", + ty: "boolean", + default: "false", + description: "Include Variable Data: write the text variables, the data sets and the type object each text variable is bound to as a variable library (variableSets) that document.open and variable.loadLibrary read back; variables of other kinds, and what is past the limits variable.loadLibrary reads (256 text variables, 1,000 data sets, 8 MiB), are left out with a warning", + }, FormatOption { name: "svg", ty: "object", diff --git a/crates/engine/src/cmd/findfiles/tests.rs b/crates/engine/src/cmd/findfiles/tests.rs index bf51ebdac7..9340e5a8c6 100644 --- a/crates/engine/src/cmd/findfiles/tests.rs +++ b/crates/engine/src/cmd/findfiles/tests.rs @@ -43,12 +43,62 @@ impl Visitor for Blocked { } } +/// A test's folder tree, removed with everything in it when dropped, also when the test panics, +/// and also when the test locked a folder in it. A removal that fails (a walker still holding a +/// file open) is ignored. +struct Tree(PathBuf); + +impl std::ops::Deref for Tree { + type Target = PathBuf; + fn deref(&self) -> &PathBuf { + &self.0 + } +} + +impl Drop for Tree { + fn drop(&mut self) { + if std::fs::remove_dir_all(&self.0).is_err() { + unlock(&self.0); + let _ = std::fs::remove_dir_all(&self.0); + } + } +} + +/// Gives the owner read, write and search permission on `folder` and on every folder in it, so +/// that a tree holding a folder a test locked can be removed. Links are not followed. +#[cfg(unix)] +fn unlock(folder: &Path) { + use std::os::unix::fs::PermissionsExt; + let Ok(meta) = std::fs::symlink_metadata(folder) else { return }; + if !meta.is_dir() { + return; + } + let mode = meta.permissions().mode() & 0o7777; + if mode & 0o700 != 0o700 { + let _ = std::fs::set_permissions(folder, std::fs::Permissions::from_mode(mode | 0o700)); + } + let Ok(entries) = std::fs::read_dir(folder) else { return }; + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|t| t.is_dir()) { + unlock(&entry.path()); + } + } +} + +/// Folders on other systems are removed as they are. +#[cfg(not(unix))] +fn unlock(_: &Path) {} + /// A fresh folder (canonical, as a search reports it) holding `paths`: files, and folders where -/// they end in `/`. -fn tree(tag: &str, paths: &[&str]) -> PathBuf { - let root = vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}")); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); +/// they end in `/`. The folder is removed when the returned [`Tree`] is dropped. +fn tree(tag: &str, paths: &[&str]) -> Tree { + let mut root = Tree(vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}"))); + // Empties what a tree whose removal failed left, keeping the folder as `temp_dir` made it + // (owner-only on Unix). + for entry in std::fs::read_dir(&root.0).unwrap().flatten() { + let path = entry.path(); + let _ = if entry.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) }; + } for p in paths { let path = root.join(p); if p.ends_with('/') { @@ -58,7 +108,54 @@ fn tree(tag: &str, paths: &[&str]) -> PathBuf { std::fs::write(&path, b"font").unwrap(); } } - plain(std::fs::canonicalize(&root).unwrap()) + root.0 = plain(std::fs::canonicalize(&root.0).unwrap()); + root +} + +#[test] +fn a_tree_is_removed_when_its_test_ends() { + let path = { + let root = tree("removed", &["a/b/one.ttf", "c/"]); + assert!(root.join("a/b/one.ttf").is_file() && root.join("c").is_dir()); + root.to_path_buf() + }; + assert!(!path.exists(), "{} is left", path.display()); +} + +#[test] +fn a_tree_is_removed_when_its_test_panics() { + let mut path = PathBuf::new(); + let r = vectorcraft_testkit::catch_quiet::<()>(|| { + let root = tree("unwound", &["a/b/one.ttf"]); + path = root.to_path_buf(); + panic!("a failed assertion"); + }); + assert_eq!(r.err().as_deref(), Some("a failed assertion")); + assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display()); +} + +#[cfg(unix)] +#[test] +fn a_tree_is_private_to_this_user() { + use std::os::unix::fs::PermissionsExt; + let root = tree("private", &["a/one.ttf"]); + let mode = std::fs::metadata(&*root).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "{}: mode {mode:o}", root.display()); +} + +#[cfg(unix)] +#[test] +fn a_tree_with_a_locked_folder_is_removed_when_its_test_panics() { + use std::os::unix::fs::PermissionsExt; + let mut path = PathBuf::new(); + let r = vectorcraft_testkit::catch_quiet::<()>(|| { + let root = tree("locked", &["a/locked/b/one.ttf"]); + path = root.to_path_buf(); + std::fs::set_permissions(root.join("a/locked"), std::fs::Permissions::from_mode(0o000)).unwrap(); + panic!("a failed assertion"); + }); + assert_eq!(r.err().as_deref(), Some("a failed assertion")); + assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display()); } /// The rules of a search of `root`: its `Users/me` is the home folder, `refused` an app's folder. diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 43d9a8e05f..f93cdca7b9 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -850,14 +850,23 @@ fn move_node(s: &mut Session, p: &Value) -> Result { ok() } -fn rect_from(p: &Value, default: Rect) -> Rect { +/// The rectangle from `x`, `y`, `width` and `height`, each defaulting to `default`'s. Sides are kept as given. +fn rect_unclamped(p: &Value, default: Rect) -> Rect { let x = f64_or(p, "x", default.x0); let y = f64_or(p, "y", default.y0); - let w = f64_or(p, "width", default.width()).max(1.0); - let h = f64_or(p, "height", default.height()).max(1.0); + let w = f64_or(p, "width", default.width()); + let h = f64_or(p, "height", default.height()); Rect::new(x, y, x + w, y + h) } +/// [`rect_unclamped`] with each supplied side raised to at least 1 pt, as `artboard.setProps` uses it. +/// A side left out keeps `default`'s, even below 1 pt. +fn rect_from(p: &Value, default: Rect) -> Rect { + let r = rect_unclamped(p, default); + let floor = |key: &str, side: f64| if p.get(key).and_then(Value::as_f64).is_some() { side.max(1.0) } else { side }; + Rect::new(r.x0, r.y0, r.x0 + floor("width", r.width()), r.y0 + floor("height", r.height())) +} + /// An artboard command's `color` param, its background color: None when absent, `Some(None)` for /// null (no color), else `Some(Some(color))`. fn artboard_color(p: &Value, cmd: &str) -> Result>> { @@ -942,7 +951,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { src + side.offset(src) } None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), - None => rect_from(p, def), + None => rect_unclamped(p, def), }; // A size taken from another artboard (`side`, or width or height left out) keeps to the limit too. if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) { diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 7727489581..162bb679f6 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -28,7 +28,9 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; -use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions}; +use vectorcraft_doc::{ + Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, +}; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; @@ -1195,6 +1197,78 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { }); } +/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without +/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not +/// allow names its file alone, without its relative path or the size and date kept of its file, +/// and each data set's image value it does not allow names its file alone too. The folder Export +/// for Screens last wrote to is left out of the export settings when it is outside the read roots. +/// A link whose path is allowed but whose relative path leads outside the read roots loses its +/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited +/// as it was before, opacity masks and the art of the stored brushes are all checked. A stored +/// brush entry that does not read as a brush is left out of the copy, since its links cannot be +/// checked, and a stored brush library that is not a list becomes an empty one. +pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Cow<'a, Document> { + if crate::file_access::current().is_none() { + return Cow::Borrowed(d); + } + let doc_path = base.doc; + // A stored relative path is followed from the folder of the file the document was read from, + // and a link is also looked for from the document's own folder: it is shown only when both + // places it names are inside the read roots. + let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from)); + let mut d = d.clone(); + // The pattern being edited as it was before is checked as one more pattern, then put back. + let original = d.pattern_edit.as_mut().and_then(|e| e.original.take()); + let edited = original.is_some(); + d.patterns.extend(original); + // The art of each stored brush is checked as more layers, then put back. + let stored = d.unknown.get(vectorcraft_brush::DOC_KEY).map(|v| v.as_array().cloned().unwrap_or_default()); + let mut brushes: Vec = + stored.iter().flatten().filter_map(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).ok()).collect(); + let layers = d.layers.len(); + d.layers.extend(brushes.iter().flat_map(|b| b.art()).map(|n| Arc::new(n.clone()))); + d.update_links_where( + |l| !path_shown(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden), + |l| { + if path_shown(&l.path, base.doc) { + l.relative = None; + } else { + let name = l.name().to_string(); + *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + } + }, + ); + let mut checked = d.layers.split_off(layers).into_iter(); + if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) { + e.original = Some(def); + } + if stored.is_some() { + // Each brush is written as it reads, so fields it does not read are left out too. + let mut kept = vec![]; + for b in &mut brushes { + for (slot, art) in b.art_mut().into_iter().zip(&mut checked) { + *slot = Arc::unwrap_or_clone(art); + } + kept.extend(serde_json::to_value(&*b).ok()); + } + d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(kept)); + } + if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) { + d.export_settings.remove("folder"); + } + let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path)); + if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) { + for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) { + if hidden(&*v) + && let DataValue::Image(path) = v + { + *path = LinkInfo::new(path.as_str()).name().to_string(); + } + } + } + Cow::Owned(d) +} + // ---------- commands ---------- fn check(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index c3b24463ae..1aeba3ad30 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind}; use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target}; use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; @@ -718,7 +718,8 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: } /// The message for a process color on a grayscale image with an alpha channel. -const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; +pub(super) const PROCESS_ON_TRANSPARENCY: &str = + "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; /// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch /// of `d`, black or white? @@ -737,11 +738,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> { let mut refused = false; let mut visit = |n: &Node| { vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { - if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref()) - && !refused - && !colorizes_transparency(d, paint) - && d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha) - { + if !refused && gray_colorized_with_process(d, m).is_some_and(|g| g.alpha) { refused = true; } }) @@ -761,6 +758,13 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R if was_ok { check_colorized(d) } else { Ok(()) } } +/// How the pixels of `n` are gray, when `n` is a grayscale or 1-bit image colorized with a color +/// that [`colorizes_transparency`] refuses. Such an image can't have an alpha channel. +pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option { + let (NodeKind::Image(im), Some(paint)) = (&n.kind, n.extras().colorize.as_deref()) else { return None }; + if colorizes_transparency(d, paint) { None } else { d.image_blob(im).and_then(|b| b.gray_source()) } +} + /// Is `e` the refusal of [`check_colorized`]? pub(super) fn is_colorize_refusal(e: &EngineError) -> bool { e.to_string().contains(PROCESS_ON_TRANSPARENCY) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 364981e4ba..e44644ea05 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -483,6 +483,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "hyphenateCapitalized", "romanHangingPunctuation", "koreanLineBreakAnywhere", + "bunriKinshi", ], C, )?; @@ -536,6 +537,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "mojikumi", "kinsoku", "kinsokuType", + "bunriKinshi", "direction", "leadingModel", "charAlign", @@ -879,6 +881,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = kinsoku_type { para.kinsoku_type = v; } + if let Some(v) = flag("bunriKinshi") { + para.bunri_kinshi = v; + } if let Some(v) = direction { para.direction = v; } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5b9e25d354..c617c4de8e 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -896,7 +896,7 @@ pub(crate) fn outlined_stroke( }; let art = d.reid(&art); let mut art = with_transparency(d, art, st.opacity, st.blend); - let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect(); + let raster = vectorcraft_render::effects::without_geometry(&st.effects); if !raster.is_empty() { if !art.appearance.effects.is_empty() { art = Node::group(d.alloc_id(), vec![Arc::new(art)]); @@ -946,7 +946,7 @@ pub(crate) fn outlined_stroke( /// geometry effects leave nothing to paint, an empty group takes the path's place. /// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, reshape, without_geometry}; if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { return None; } @@ -975,7 +975,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } let mut plain = moved; - plain.effects_mut().retain(|e| !is_geometry(&e.id)); + *plain.effects_mut() = without_geometry(plain.effects()); parts.push(styled_copy(d, &shape, vec![plain])); } AppearanceItem::Stroke(st) if outlinable(st) => { @@ -983,7 +983,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } let mut plain = st.clone(); - plain.effects.retain(|e| !is_geometry(&e.id)); + plain.effects = without_geometry(&st.effects); let stroked = match path.bounds() { Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)), _ => path.clone(), diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index f0636899cf..99cfc0a3fc 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -245,6 +245,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("burasagari", json!(v.burasagari)), ("kinsoku", json!(v.kinsoku)), ("kinsoku_type", json!(v.kinsoku_type)), + ("bunri_kinshi", json!(v.bunri_kinshi)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), ("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)), diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index b47817d8d7..1ed1231737 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -440,6 +440,59 @@ fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() { assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); } +/// Paragraph panel menu › Bunri-Kinshi: off for new type and for a paragraph saved before it, and +/// saved only when on; `text.setFormat {bunriKinshi}` sets it per paragraph in one undo step and +/// refuses a value that is not true or false, kinsoku None keeps it, and paragraph styles carry it. +#[test] +fn bunri_kinshi_is_set_per_paragraph_and_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "あ——い\nう……え"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert!(!text(&s).para_at(0).bunri_kinshi, "new type has it off"); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("bunri_kinshi").is_none()); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert!(!old.bunri_kinshi, "saved before the option: off"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + // Only the second paragraph. + let second = "あ——い\n".len(); + s.execute("text.setFormat", &json!({"bunriKinshi": true, "start": second, "end": second})).unwrap(); + assert!(!text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let saved = serde_json::to_value(text(&s).para_at(1)).unwrap(); + assert_eq!(saved["bunri_kinshi"], true); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(saved).unwrap(); + assert!(back.bunri_kinshi); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert!(!back.para_at(0).bunri_kinshi && back.para_at(1).bunri_kinshi, "the file keeps it per paragraph"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!text(&s).para_at(1).bunri_kinshi); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + for bad in [json!("yes"), json!(1)] { + assert!(s.execute("text.setFormat", &json!({"ids": [id], "bunriKinshi": bad, "kinsoku": "none"})).is_err(), "{bad}"); + } + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "bunriKinshi": false})).is_err()); + assert!(text(&s).para_at(1).bunri_kinshi && text(&s).para_at(1).kinsoku != vectorcraft_doc::Kinsoku::None, "a refused call changes nothing"); + // Kinsoku None keeps the setting, without effect, and turning kinsoku back on restores it. + s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "none"})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "hard"})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Pairs", "attrs": {"bunri_kinshi": true}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Pairs", "id": id})).unwrap(); + assert!(text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi); + s.execute("paraStyle.new", &json!({"name": "Split", "attrs": {"bunri_kinshi": false}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Split", "id": id})).unwrap(); + assert!(!text(&s).para_at(0).bunri_kinshi && !text(&s).para_at(1).bunri_kinshi); +} + /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one /// undo step, the paragraph is saved with it only when it is not None and opens with it, an /// unknown value changes nothing, and paragraph styles carry it. diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index ad70f047bc..241487112b 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -27,6 +27,7 @@ mod csv; mod library; use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; +pub(crate) use library::{give_svg_library, svg_library}; use library::{load_library, save_library}; /// The longest variable or dataset name, in characters (names arrive from MCP and files). diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index 54f3689e99..e1f4ae0bd5 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -1,6 +1,8 @@ +use std::collections::HashMap; + use super::{ - BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, absolute_path, bad, json, named, - read_file_head, str_param, variables_unlocked, write_file, + BTreeMap, BTreeSet, DataSet, DataValue, Document, EngineError, MAX_NAME, NodeId, NodeKind, Result, Session, Value, Variable, VariableKind, + Variables, absolute_path, bad, json, named, read_file_head, str_param, variables_unlocked, write_file, }; /// The namespace of a variable library's elements, the default namespace of its data sets' values, @@ -169,12 +171,20 @@ fn without_doctype(xml: &str, cmd: &str) -> Result { } /// A variable library as Load Variable Library reads it: the names of its text variables in the -/// order it declares them, the name and `trait` of each variable of another kind, and its data -/// sets with their text values. -struct Library { +/// order it declares them, the name and `trait` of each variable of another kind, its data sets +/// with their text values, and the element id each text variable's `docRef` names. +pub(crate) struct Library { text: Vec, other: Vec<(String, String)>, datasets: Vec, + refs: Vec<(String, String)>, +} + +impl Library { + /// The element ids the text variables' `docRef`s name. + pub(crate) fn elements(&self) -> std::collections::HashSet { + self.refs.iter().map(|(_, e)| e.clone()).collect() + } } /// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each @@ -200,7 +210,7 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, "the file holds no `variableSets` element")); }; let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); - let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new()); + let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new()); for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { let name = named(var.attribute("varName"), "varName", cmd)?; if !declared.insert(name.clone()) { @@ -210,7 +220,14 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); } match var.attribute("trait") { - Some("textcontent") => text.push(name), + Some("textcontent") => { + // `docRef="id('…')"` names the element of the variable's bound object. + let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + if let Some(element) = element { + refs.push((name.clone(), element.to_string())); + } + text.push(name); + } Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())), None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))), } @@ -253,7 +270,152 @@ fn parse_library(xml: &str, cmd: &str) -> Result { } datasets.push(DataSet { name, values }); } - Ok(Library { text, other, datasets }) + Ok(Library { text, other, datasets, refs }) +} + +/// The variable library an SVG carries (SVG Options › Include Variable Data), read as +/// [`parse_library`] reads one, or `None` when the SVG holds no `variableSets` element. The +/// element is read on its own, from its start tag to the end tag that closes it +/// ([`variable_sets_range`]), with each reference to an +/// entity whose name starts with `ns_` replaced by the value the text before the element declares +/// for it (up to [`MAX_LIBRARY_ENTITIES`] of them, plain text of at most [`MAX_NAME`] bytes), as +/// files name the library's namespaces with such entities. The `ns_vars`, `ns_custom` and +/// `ns_flows` entities take the library's namespaces when the file doesn't declare them. An +/// element larger than [`MAX_LIBRARY_BYTES`], before or after the replacements, is refused. +pub(crate) fn svg_library(svg: &str) -> Option> { + const C: &str = "document.open"; + let (start, end) = variable_sets_range(svg)?; + let read = || { + let end = end.ok_or_else(|| bad(C, "the variable library declares no variables"))?; + let element = svg.get(start..end).unwrap_or_default(); + if element.len() > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut entities: Vec<(String, String)> = Vec::new(); + for decl in svg.get(..start).unwrap_or_default().split("(); + if element.len() + grown > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut element = element.to_string(); + for (name, value) in &entities { + element = element.replace(name.as_str(), value); + } + parse_library(&format!("{element}"), C) + }; + Some(read()) +} + +/// Where the first `variableSets` element of `svg` that is markup starts, and where the end tag +/// that closes it ends (`None` when no end tag does), or `None` when there is no such element. +/// Comments, CDATA sections, processing instructions and the document type declaration are +/// skipped, so text inside them that looks like the element is not taken for it. +fn variable_sets_range(svg: &str) -> Option<(usize, Option)> { + const NAME: &str = "variableSets"; + // The element's name ends at white space, `>` or `/`. + let named = |rest: &str| rest.strip_prefix(NAME).is_some_and(|r| r.starts_with(|c: char| c.is_ascii_whitespace() || matches!(c, '>' | '/'))); + if !svg.contains("") + } else if rest.starts_with("") + } else if rest.starts_with("") + } else if rest.starts_with("`. + match (rest.find('['), rest.find('>')) { + (Some(open), Some(gt)) if open < gt => { + rest.get(open..).and_then(|s| s.find(']')).and_then(|c| rest.get(open + c..)?.find('>').map(|g| at + open + c + g + 1)) + } + (_, gt) => gt.map(|g| at + g + 1), + } + } else if let Some(close) = rest.strip_prefix(""); + if let Some(s) = start + && named(close) + { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some((s, tag_end)); + } + } + tag_end + } else { + let tag_end = past(">"); + if named(rest.get(1..).unwrap_or_default()) { + let empty = tag_end.and_then(|e| svg.get(..e)).is_some_and(|t| t.ends_with("/>")); + if start.is_none() { + start = Some(at); + } + if empty && depth == 0 { + return Some((at, tag_end)); + } + depth += usize::from(!empty); + } + tag_end + }; + let Some(next) = next else { break }; + at = next; + } + start.map(|s| (s, None)) +} + +/// Give `doc`, opened from an SVG, the variable data `lib` it carries ([`svg_library`]): the +/// library's text variables, its data sets, no active data set, and each text variable bound to +/// the type object made of the element its `docRef` names, or the one type object among the +/// objects a group made of it holds (`found`: the object made of each element, by id). A `docRef` +/// that names no type object, or one another variable is bound to already, binds nothing. The art +/// stays as the file draws it. The warning about the variables of other kinds that were left out, +/// if any. +pub(crate) fn give_svg_library(doc: &mut Document, lib: Library, found: &HashMap) -> Option { + let mut vars = + Variables { variables: lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(), ..Variables::default() }; + vars.datasets = lib.datasets; + // A type object written with a paint of its own, or with fills and strokes of its appearance, + // is a group around its text (and the appearance's paths). + let text_of = |id: NodeId| match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(_)) => Some(id), + Some(NodeKind::Group { children, .. }) => { + let mut texts = children.iter().filter(|c| matches!(c.kind, NodeKind::Text(_))); + match (texts.next(), texts.next()) { + (Some(one), None) => Some(one.id), + _ => None, + } + } + _ => None, + }; + for (name, element) in &lib.refs { + let Some(id) = found.get(element).and_then(|id| text_of(*id)) else { continue }; + if !vars.bindings.contains_key(&id) { + vars.bind(id, name); + } + } + doc.variables = vars.into(); + left_out_warning(lib.other.iter().map(|(_, t)| t.as_str())) } /// Load Variable Library: replace the document's variables and data sets with a variable library's diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 95b44776f0..c9de286514 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -84,7 +84,7 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { /// Queries that apply the edit the active tool is computing before they run, as edits do /// ([`Session::finish_tool_edit`]): a batch runs edits, Find Objects with `select`, Select All /// Instances and Lock Guides change the selection, and the proof views, the preferences and added -/// fonts give the documents new revisions (they draw again, and type lays out again). +/// fonts can give the documents new revisions (they draw again, and type lays out again). const WAIT_FOR_TOOL_EDIT: &[&str] = &[ "script.run", "command.batch", diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 58832adf29..146583778f 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -287,7 +287,8 @@ fn document_base(s: &Session) -> Value { "title": st.title(), // The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name. "fileInfoTitle": d.info_title(), - "path": st.path, + // Null for a confined caller when the file is outside its read roots. + "path": st.path.as_deref().filter(|p| crate::file_access::check_read(p).is_ok()), "dirty": st.is_dirty(), "revision": st.revision, "units": d.units.label(), diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 0fc2bcb476..49a806b258 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -793,8 +793,9 @@ fn shape_builder_applies_a_merge_before_what_comes_next() { let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect(); assert_eq!(labels, ["Move", "Shape Builder"]); }), - // The proof views redraw every document (a new revision); they are left off, as the - // other tests of the process-wide proof view expect. + // The proof views apply the merge first by command id (`WAIT_FOR_TOOL_EDIT`), also when + // the view does not change and no document gets a new revision. They are left off, as + // the other tests of the process-wide proof view expect. ("Separations Preview", |s, _, _| { s.execute("view.separationsPreview", &json!({"on": false})).unwrap(); }), @@ -807,6 +808,13 @@ fn shape_builder_applies_a_merge_before_what_comes_next() { ("Proof Setup", |s, _, _| { s.execute("view.proofSetup", &json!({})).unwrap(); }), + ("a proof view that changes", |s, _, _| { + let _v = crate::tests_colormgmt::DefaultView::new(); + s.execute("view.proofColors", &json!({"on": true})).unwrap(); + let on = s.doc().unwrap().revision; + s.execute("view.proofColors", &json!({"on": false})).unwrap(); + assert!(s.doc().unwrap().revision > on, "the view changes and the documents get new revisions"); + }), ("a preference", |s, _, _| { let every = s.prefs.gridline_every; s.execute("prefs.set", &json!({"key": "gridlineEvery", "value": every + 10.0})).unwrap(); diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index ae8ac0c4f0..493f569805 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -455,6 +455,7 @@ fn overprint_black_multiplies_in_preview() { #[test] fn view_toggles_redraw_without_dirtying() { let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); let mut s = session(); let rev = s.doc().unwrap().revision; let r = s.execute("view.proofColors", &json!({"on": true})).unwrap(); @@ -475,6 +476,101 @@ fn view_toggles_redraw_without_dirtying() { s.execute("view.overprintPreview", &json!({"on": false})).unwrap(); } +/// Sets the default proof view when made and again when dropped, also after a failed assertion, +/// so the next test starts from the default view. +pub(crate) struct DefaultView; + +impl DefaultView { + pub(crate) fn new() -> Self { + vectorcraft_render::proof::set_view(Default::default()); + DefaultView + } +} + +impl Drop for DefaultView { + fn drop(&mut self) { + vectorcraft_render::proof::set_view(Default::default()); + } +} + +/// Runs `cmd` with `p` twice from the default proof view: the first run changes the view and +/// redraws, and the second asks for the view that is already showing and leaves the revision alone. +fn repeat_keeps_revision(cmd: &str, p: Value) { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let rev = s.doc().unwrap().revision; + s.execute(cmd, &p).unwrap(); + let changed = s.doc().unwrap().revision; + assert!(changed > rev, "{cmd} {p}: the first run changes the view"); + s.execute(cmd, &p).unwrap(); + assert_eq!(s.doc().unwrap().revision, changed, "{cmd} {p}: the same view again"); +} + +/// Runs `cmd` with `p` once from the default proof view, which `p` leaves as it is. +fn default_view_keeps_revision(cmd: &str, p: Value) { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let rev = s.doc().unwrap().revision; + s.execute(cmd, &p).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev, "{cmd} {p}"); + assert_eq!(vectorcraft_render::proof::view(), Default::default(), "{cmd} {p}"); +} + +#[test] +fn proof_setup_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.proofSetup", json!({"target": "deuteranopia"})); + repeat_keeps_revision("view.proofSetup", json!({"simulatePaper": true, "proof": true})); + default_view_keeps_revision("view.proofSetup", json!({})); + default_view_keeps_revision("view.proofSetup", json!({"target": "workingCmyk", "proof": false})); +} + +#[test] +fn proof_colors_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.proofColors", json!({"on": true})); + default_view_keeps_revision("view.proofColors", json!({"on": false})); +} + +#[test] +fn overprint_preview_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.overprintPreview", json!({"on": true})); + default_view_keeps_revision("view.overprintPreview", json!({"on": false})); +} + +#[test] +fn separations_preview_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.separationsPreview", json!({"on": true})); + repeat_keeps_revision("view.separationsPreview", json!({"only": "Cyan"})); + repeat_keeps_revision("view.separationsPreview", json!({"plates": ["Magenta", "Black"]})); + default_view_keeps_revision("view.separationsPreview", json!({"on": false})); +} + +/// Separations Preview's visible plates are a set: the same plates in another order, or with one +/// named twice, leave the revision alone, and a plate named twice hides with one `toggle`. +#[test] +fn separations_preview_compares_the_visible_plates_as_a_set() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let sep = |s: &mut Session, p: Value| { + s.execute("view.separationsPreview", &p).unwrap(); + s.doc().unwrap().revision + }; + let shown = sep(&mut s, json!({"plates": ["Magenta", "Black"]})); + assert_eq!(sep(&mut s, json!({"plates": ["Black", "Magenta"]})), shown, "another order"); + assert_eq!(sep(&mut s, json!({"plates": ["Magenta", "Black", "Black"]})), shown, "a plate named twice"); + // Cyan hidden and shown again goes to the end of the list; every plate is shown. + sep(&mut s, json!({"on": false})); + sep(&mut s, json!({"on": true})); + sep(&mut s, json!({"toggle": "Cyan"})); + let all = sep(&mut s, json!({"toggle": "Cyan"})); + assert_eq!(sep(&mut s, json!({"plates": ["Cyan", "Magenta", "Yellow", "Black"]})), all, "every plate in plate order"); + sep(&mut s, json!({"plates": ["Cyan", "Cyan"]})); + let r = s.execute("view.separationsPreview", &json!({"toggle": "Cyan"})).unwrap(); + assert!(r["plates"].as_array().unwrap().iter().all(|p| p["visible"] == false), "{r}"); +} + #[test] fn proof_setup_simulates_tritanopia() { let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/engine/src/tests/css.rs b/crates/engine/src/tests/css.rs index 764990b100..1ce5b09e41 100644 --- a/crates/engine/src/tests/css.rs +++ b/crates/engine/src/tests/css.rs @@ -98,6 +98,69 @@ fn export_writes_the_sheet_and_rasterized_art() { assert!(s.execute("css.export", &json!({})).is_err(), "nothing selected: nothing to export"); } +#[test] +fn export_refuses_a_scope_or_path_that_is_not_a_string() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})); + run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40})); + let refused = |s: &mut Session, p: Value, key: &str| { + let e = s.execute("css.export", &p).expect_err(&p.to_string()).to_string(); + assert_eq!(e, format!("invalid parameters for `css.export`: `{key}` must be a string"), "{p}"); + }; + for scope in [json!(5), json!(true), json!(["all"]), Value::Null] { + refused(&mut s, json!({"scope": scope}), "scope"); + } + for path in [json!(5), Value::Null] { + refused(&mut s, json!({"path": path}), "path"); + refused(&mut s, json!({"path": path, "scope": "all"}), "path"); + } + // Left out, each keeps its default: the selection (the ellipse), and the CSS as data. + let r = run(&mut s, "css.export", json!({})); + assert_eq!((r["rules"].clone(), r["data"].is_string()), (json!(1), true), "{r}"); + assert_eq!(run(&mut s, "css.export", json!({"scope": "all"}))["rules"], json!(2)); + let e = s.execute("css.export", &json!({"scope": "page"})).unwrap_err().to_string(); + assert!(e.contains("scope must be selection or all (got page)"), "{e}"); +} + +#[test] +fn every_object_refuses_malformed_ids_it_does_not_use() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"].as_u64().unwrap(); + run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40})); + for (cmd, base) in [("css.generate", json!({})), ("css.export", json!({"scope": "all"}))] { + for (key, value, says) in [ + ("ids", json!("abc"), "`ids` must be an array of object ids, not \"abc\""), + ("ids", json!([1.5]), "object ids are non-negative integers, not 1.5"), + ("id", json!(-1), "object ids are non-negative integers, not -1"), + ("id", json!("$step"), "`id` is the step reference \"$step\""), + ] { + let mut p = base.clone(); + p[key] = value; + let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")).to_string(); + assert!(e.contains(says), "{cmd} {p}: {e}"); + } + // Well-formed ids are still not used: every object. + let mut p = base.clone(); + p["ids"] = json!([rect]); + let rules = run(&mut s, cmd, p)["rules"].clone(); + let count = rules.as_array().map_or(rules.as_u64(), |r| Some(r.len() as u64)); + assert_eq!(count, Some(2), "{cmd}: {rules}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_with_a_scope_that_is_not_a_string_writes_nothing() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})); + let dir = std::env::temp_dir().join(format!("vc-css-scope-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("site.css"); + assert!(s.execute("css.export", &json!({"path": path.to_string_lossy(), "scope": 5})).is_err()); + assert!(!path.exists(), "no file for a refused scope"); + std::fs::remove_dir_all(&dir).ok(); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn export_to_a_path_puts_the_pictures_next_to_the_sheet() { diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 0b573a96f3..d2e5f1b115 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -291,6 +291,20 @@ fn file_new_and_artboard_new_take_the_same_largest_side() { assert!(err.contains(in_meters), "{p}: {err}"); } assert_eq!(boards(&s).len(), 2); + + // A side below 1 pt is kept as given, as file.new keeps it, and a size taken from such an artboard keeps it too. + let mut s = new_doc(json!({"width": 0.5, "height": 0.25})); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 0.25)]); + s.execute("artboard.new", &json!({"x": 0, "y": 10, "width": 0.5, "height": 0.75})).unwrap(); + s.execute("artboard.new", &json!({"x": 0, "y": 20})).unwrap(); + assert_eq!(boards(&s)[1..], [Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]); + // artboard.setProps keeps such sides when it changes something else, on a locked artboard too, + // and still raises a supplied side to 1 pt. + s.execute("artboard.setProps", &json!({"index": 1, "name": "Small"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "name": "Small locked"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "height": 0.5})).unwrap(); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 1.0), Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]); } #[test] diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 3dacee533b..081db4cca0 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -191,6 +191,27 @@ fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() { } } +/// A Transform effect after a drop shadow, on the object or on the fill, scales and turns the +/// shadow on the canvas; Object › Expand of the gradient fill keeps the shadow the canvas drew. +#[test] +fn a_shadow_before_a_transform_expands_as_the_canvas_draws_it() { + for item in [json!(null), json!(0)] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false); + for (effect, params) in [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})), + ("distort.transform", json!({"scaleH": 200, "scaleV": 200, "rotate": 90})), + ] { + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + } + let before = render(&s); + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + let diff = differing(&before, &after, 64); + assert_eq!(diff, 0, "item {item}: {diff} pixels differ"); + } +} + #[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/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index d3394d1481..a92ca10416 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -308,6 +308,30 @@ fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() { assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did"); } +/// A Transform effect after a drop shadow, on the object or on the stroke (brushed or not), turns +/// and scales the shadow on the canvas; outlining the stroke keeps the shadow the canvas drew. +#[test] +fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() { + let effects = [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})), + ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "rotate": 90})), + ]; + for (item, brush) in [(Value::Null, false), (json!(1), false), (json!(1), true)] { + // Flatten Transparency draws a brushed stroke's drop shadow differently with or without a + // Transform effect, so the brushed stroke is checked with the other two commands. + for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) { + let mut s = session(); + square_with(&mut s, json!({"color": "#ff0000"}), item.clone(), &effects); + if brush { + s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap(); + } + let before = render(&s); + outlined_by(&mut s, command, &p); + assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, item {item}, brush {brush}"); + } + } +} + /// The brush art of a brushed stroke with a geometry effect follows the path the effect makes. #[test] fn a_brush_draws_along_the_path_the_stroke_effects_make() { diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index e7d3c7cd7a..1a6ee921f2 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1084,6 +1084,102 @@ fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() { assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}"); } +/// `document.inspect` and `document.json` for a confined caller: the document's file, a placed +/// document and a linked image outside the read roots are not named by path, and the document +/// keeps its links. +#[test] +fn document_replies_leave_out_paths_outside_the_read_roots() { + let away = Folder::new("doc-lrr-away"); + let home = Folder::new("doc-lrr-home"); + let src = away.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let mut s = session(); + let id = place(&mut s, &src); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap(); + s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap(); + let image = r["ids"][0].as_u64().unwrap(); + let poster = away.file("poster.vectorcraft"); + save(&mut s, &poster); + // The folder Export for Screens wrote to, and a pattern of the linked image being edited: its + // definition before editing is kept beside the tile art. + let out = away.file("out"); + s.execute("document.exportForScreens", &json!({"folder": out, "formats": [{"format": "svg"}]})).unwrap(); + let pattern = s.execute("object.pattern.make", &json!({"ids": [image], "edit": false})).unwrap()["name"].clone(); + s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap(); + assert!(s.doc().unwrap().doc.pattern_edit.as_ref().is_some_and(|e| e.original.is_some())); + let ask = |s: &mut Session, root: &std::path::Path, cmd: &str| { + let roots = crate::file_access::AutomationRoots::new(&[root], &[]).unwrap().unwrap(); + crate::file_access::confine(Some(&roots), || s.execute(cmd, &json!({}))).unwrap() + }; + let inspect = ask(&mut s, &home.0, "document.inspect"); + assert!(!inspect.to_string().contains("doc-lrr-away"), "{inspect}"); + assert!(inspect["path"].is_null() && inspect["uid"].is_u64(), "{inspect}"); + let model = ask(&mut s, &home.0, "document.json").to_string(); + assert!(!model.contains("doc-lrr-away"), "{model}"); + for name in ["logo.vectorcraft", "photo.png"] { + assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}"); + } + assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}"); + // The document keeps its links, and a caller whose read roots hold the files sees every path. + assert_eq!(placed(&s, id).link.path, src); + for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] { + let model = model.to_string(); + for path in [&src, &pic, &out] { + assert!(model.contains(&json!(path).to_string()), "{path}: {model}"); + } + } + assert_eq!(ask(&mut s, &away.0, "document.inspect")["path"], json!(poster)); + assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster)); +} + +/// `document.json` for a confined caller: a linked image in a brush's art outside the read roots +/// is named by its file name, a stored brush entry that does not read as a brush is left out, and a +/// link inside them whose stored relative path leads outside them loses that relative path. +#[test] +fn document_json_checks_brush_art_and_relative_paths_against_the_read_roots() { + let away = Folder::new("doc-brr-away"); + let home = Folder::new("doc-brr-home"); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let near_pic = home.file("near.png"); + write(&near_pic, &crate::tests_links::png(20, 10, RED)); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + let image = r["ids"][0].as_u64().unwrap(); + let r = s.execute("file.place", &json!({"path": near_pic, "at": [150, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("brush.new", &json!({"type": "art", "name": "Photo Band", "ids": [image]})).unwrap(); + save(&mut s, &home.file("poster.vectorcraft")); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let lib = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut).unwrap(); + lib.push(json!({"name": "Broken Band", "type": "unknown kind", "note": pic})); + d.update_links(|l| { + if l.name() == "near.png" { + l.relative = Some("../doc-brr-away/near.png".into()); + } + }); + } + let unconfined = s.execute("document.json", &json!({})).unwrap().to_string(); + for part in [json!(pic).to_string(), "Broken Band".into(), "../doc-brr-away/near.png".into()] { + assert!(unconfined.contains(&part), "{part}: {unconfined}"); + } + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let model = crate::file_access::confine(Some(&roots), || s.execute("document.json", &json!({}))).unwrap(); + let text = model.to_string(); + assert!(!text.contains("doc-brr-away") && !text.contains("Broken Band"), "{text}"); + assert!(text.contains("\"path\":\"photo.png\"") && text.contains("Photo Band"), "{text}"); + assert!(text.contains(&json!(near_pic).to_string()), "{text}"); + // The document keeps its brushes and links. + let stored = s.doc().unwrap().doc.unknown.get(vectorcraft_brush::DOC_KEY).unwrap().to_string(); + assert!(stored.contains("Broken Band") && stored.contains(&json!(pic).to_string()), "{stored}"); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 8d1570e996..bb02ead656 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -452,6 +452,60 @@ fn a_painted_effect_on_a_fill_and_refused_results() { } } +/// A fill's Drop Shadow before a painted effect that returns two fills, and a 200% Transform effect +/// after it: the shadow of each returned fill follows the Transform effect. The square the effect +/// returns spans 30 to 70 pt once scaled, so both half-opaque shadows, 20 pt to the right, cover +/// 85 pt as they cover 75 pt. +#[test] +fn a_shadow_on_each_fill_a_painted_effect_returns_follows_the_transform_after_it() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &out)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for (effect, params) in [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 50})), + ("plugin.test.engine.twofills", json!({})), + ("distort.transform", json!({"scaleH": 200, "scaleV": 200})), + ] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "params": params, "item": fill})); + } + let img = drawn(&s); + let (near, far) = (img.pixel(75, 50), img.pixel(85, 50)); + assert_ne!(near, WHITE, "the shadows reach past the square"); + assert_eq!(far, near, "both shadows at 20 pt"); +} + +/// A Transform effect with Scale Strokes & Effects on after a painted effect on a fill leaves the +/// Drop Shadow after it as it is on every paint the piece is drawn with. +#[test] +fn a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twopaints", true), &two)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twopaints", "item": fill})); + let mut n = node(&s, id); + let later = [ + vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"scaleH": 200, "scaleV": 200, "strokes": true}), visible: true }, + vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: json!({"x": 10.0, "y": 0.0, "blur": 0.0}), visible: true }, + ]; + n.appearance.items[fill].effects_mut().extend(later); + let art = vectorcraft_render::effects::reshape(Some(&s.doc().unwrap().doc), &n, None).unwrap(); + let mut offsets = vec![]; + art.walk(&mut |m| { + for item in &m.appearance.items { + for r in vectorcraft_render::effects::raster_effects(item.effects()) { + if let vectorcraft_render::effects::RasterFx::DropShadow { dx, dy, .. } = r { + offsets.push((dx, dy)); + } + } + } + }); + assert_eq!(offsets, [(10.0, 0.0), (10.0, 0.0)], "one shadow on each of the piece's two fills"); +} + /// A result refused for a swatch the document doesn't have stays cached: once the document has the /// swatch, the next check of that result clears the error. #[test] diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 06b1cf3144..adcc35d352 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -568,6 +568,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { ("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"), ("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"), ("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"), + ("text.setFormat", json!({"ids": [id.0], "bunriKinshi": "on"}), "bunriKinshi"), ("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"), ("type.opticalMarginAlignment", json!({"on": "true"}), "on"), ("type.systemLayout", json!({"on": "true"}), "on"), diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 4b773c889c..08ba426ef1 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2916,3 +2916,191 @@ fn applying_a_graph_row_again_records_no_undo_step() { s.execute("dataset.select", &json!({"name": "B"})).unwrap(); assert_eq!(super::history(&s), (before.0 + 1, 0, true)); } + +/// The document's SVG with the options `svg` → (its text, the export's warnings). +fn svg_export(s: &mut Session, svg: Value) -> (String, Vec) { + let r = s.execute("document.export", &json!({"format": "svg", "svg": svg})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8(bytes).unwrap(), r["warnings"].as_array().cloned().unwrap_or_default()) +} + +/// A new session with `svg` opened as `name` → (the session, the open's warnings). +fn svg_opened(svg: &str, name: &str) -> (Session, Vec) { + let mut o = Session::new(); + let r = o.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); + (o, warnings) +} + +/// Include Variable Data writes the text variables, the data sets' text values and each text +/// variable's bound type object into the SVG, and opening the SVG reads them back: the type +/// objects are bound again, so a data set changes them. Variables of other kinds are left out with +/// a warning. Unnamed objects get ids with Minimal ids, minified files, unique ids and style +/// entities alike, and Load Variable Library reads the same file. +#[test] +fn an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let u = text(&mut s, "9.99"); + // A fill of its appearance writes the type in a group with the fill's path. + s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap(); + let r = rect(&mut s); + for (name, kind) in [("Name", "text"), ("Shown", "visibility"), ("Price", "text")] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + } + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Shown", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A & ", "values": {"Name": "Bob", "Price": "1.00\nper pound & tax\r", "Shown": false}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Second", "values": {"Name": ""}})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + let text_only: Vec = before + .datasets + .iter() + .map(|d| { + let mut d = d.clone(); + d.values.retain(|k, _| k != "Shown"); + d + }) + .collect(); + + for opts in [ + json!({"variableData": true}), + json!({"variableData": true, "objectIds": "minimal", "minify": true}), + json!({"variableData": true, "objectIds": "unique"}), + json!({"variableData": true, "styling": "entities", "encoding": "latin1"}), + ] { + let (svg, warnings) = svg_export(&mut s, opts.clone()); + assert!(warnings.contains(&json!("Variables that aren't text variables were left out of the SVG's variable data")), "{opts}: {warnings:?}"); + assert_eq!(svg.matches("docRef=\"id('").count(), 2, "{opts}: {svg}"); + assert_eq!(s.doc().unwrap().doc.variables, before, "{opts}: exporting changes nothing"); + + let (mut o, warnings) = svg_opened(&svg, "vars.svg"); + assert!(warnings.is_empty(), "{opts}: {warnings:?}"); + let vars = o.doc().unwrap().doc.variables.clone(); + assert_eq!(vars.variables.iter().map(|v| (v.name.as_str(), v.kind.label())).collect::>(), [("Name", "text"), ("Price", "text")]); + assert_eq!(vars.datasets, text_only, "{opts}"); + assert_eq!(vars.active_dataset, None); + let bound = |name: &str| { + let ids = vars.objects_of(name); + assert_eq!(ids.len(), 1, "{opts}: {name}"); + ids[0] + }; + let (name, price) = (bound("Name"), bound("Price")); + assert_eq!((plain(&o, name), plain(&o, price)), ("Alice".into(), "9.99".into()), "{opts}: the art stays as the file draws it"); + let v = o.execute("dataset.select", &json!({"name": "A & "})).unwrap(); + assert_eq!(v["applied"], json!(2), "{opts}: {v}"); + assert_eq!(plain(&o, name), "Bob"); + + let mut lib = session(); + lib.execute("variable.loadLibrary", &json!({"xml": svg})).unwrap(); + assert_eq!(lib.doc().unwrap().doc.variables.datasets, text_only, "{opts}"); + } +} + +/// Without Include Variable Data the SVG holds no variable data, and a document without variables +/// writes none with it on. Outlined type is written unbound, characters XML can't hold are left +/// out of the values, and variable data past the 8 MiB or 2,000,000-node cap is left out, each +/// with a warning; past 256 text variables or 1,000 data sets the rest are left out with a +/// warning. Look-alike text in comments and other markup is skipped when the file opens. A file +/// whose variable data can't be read opens its art with a warning, and so does one whose +/// namespace entities would grow the library past its cap. +#[test] +fn an_svg_without_readable_variable_data_opens_its_art() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let (plain_svg, _) = svg_export(&mut s, json!({})); + assert!(!plain_svg.contains("variableSets")); + let (none, _) = svg_export(&mut s, json!({"variableData": true})); + assert!(!none.contains("variableSets"), "no variables, no variable data"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + let (off, _) = svg_export(&mut s, json!({})); + assert!(!off.contains("variableSets"), "off by default"); + // Outlined type opens as art: the variable is written unbound, and the values lose the + // characters XML can't hold, each with a warning. + s.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\u{b}b\u{1}c"}})).unwrap(); + let (outlined, warnings) = svg_export(&mut s, json!({"variableData": true, "outlineText": true})); + assert!(outlined.contains(""; + let art = ""; + let untraited = format!("{head}{art}"); + let refs = "&ns_big;".repeat(40_000); + let big = "x".repeat(250); + let growing = format!( + "]>{head}{art}" + ); + // Variable data under 8 MiB with more XML nodes than Load Variable Library reads (a million + // one-character paragraphs, minified) is left out with a warning. + let mut lines = session(); + lines.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + lines.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\n".repeat(1_000_000)}})).unwrap(); + let (dense, warnings) = svg_export(&mut lines, json!({"variableData": true, "minify": true})); + assert!(!dense.contains("variableSets")); + assert!( + warnings.contains(&json!( + "The variable data holds more than 2,000,000 XML nodes, which Load Variable Library reads, and was left out of the SVG" + )), + "{warnings:?}" + ); + + // More text variables and data sets than Load Variable Library reads: the first 256 and the + // first 1,000 are written, each with a warning, and the file opens with them. + let mut many = session(); + { + let vars = &mut std::sync::Arc::make_mut(&mut many.doc_mut().unwrap().doc).variables; + vars.variables = (0..300).map(|i| vectorcraft_doc::Variable { name: format!("V{i}"), kind: vectorcraft_doc::VariableKind::Text }).collect(); + vars.datasets = (0..1_100) + .map(|i| vectorcraft_doc::DataSet { + name: format!("D{i}"), + values: [("V0".to_string(), vectorcraft_doc::DataValue::Text(format!("{i}")))].into_iter().collect(), + }) + .collect(); + } + let (capped, warnings) = svg_export(&mut many, json!({"variableData": true})); + for w in [ + "Text variables past the first 256, which Load Variable Library reads, were left out of the SVG's variable data", + "Data sets past the first 1,000, which Load Variable Library reads, were left out of the SVG's variable data", + ] { + assert!(warnings.contains(&json!(w)), "{warnings:?}"); + } + let (o, warnings) = svg_opened(&capped, "many.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + let vars = &o.doc().unwrap().doc.variables; + assert_eq!((vars.variables.len(), vars.datasets.len()), (256, 1_000)); + assert_eq!(vars.datasets[999].values["V0"], vectorcraft_doc::DataValue::Text("999".into())); + + // Text that only looks like variable data, in a comment, a CDATA section, a processing + // instruction or the document type declaration, is skipped for the element itself. + let library = ""; + let decoys = format!( + " -->]>{head} ]]> ?>{library}{art}" + ); + let (o, warnings) = svg_opened(&decoys, "decoys.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(o.doc().unwrap().doc.variables.variables.len(), 1); + + for (svg, why) in [(untraited, "variable `A` has no `trait`"), (growing, "entities expand to more than")] { + let (o, warnings) = svg_opened(&svg, "broken.svg"); + assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("variable data wasn't read") && w.contains(why))), "{warnings:?}"); + let d = &o.doc().unwrap().doc; + assert!(d.variables.variables.is_empty()); + assert_eq!(vectorcraft_testkit::svg_shapes(&vectorcraft_svg::export(d, &Default::default())), 1, "the art opens"); + } +} diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index c84921f6de..c3d6df771b 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -446,6 +446,60 @@ fn loading_the_working_rgb_profile_again_takes_effect_at_once() { assert!(redrawn, "the canvas redraws"); } +/// Loading a new file of the CMYK profile View → Proof Setup targets, or removing that profile, +/// gives the documents new revisions, as a working space's new file does; loading or removing +/// another profile does not. +#[test] +fn loading_or_removing_the_proof_profile_redraws() { + let _g = settings(); + // A built-in CMYK profile's file renamed `name`: padded with spaces, which names drop. + let renamed = |builtin: &str, name: &str| { + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(&format!("{name: revision, "the proof profile's file"); + exec(&mut s, "color.removeProfile", json!({"name": other})); + assert_eq!(s.doc().unwrap().revision, reloaded, "another profile removed"); + exec(&mut s, "color.removeProfile", json!({"name": proofed})); + assert!(s.doc().unwrap().revision > reloaded, "the proof profile removed"); +} + +/// Sets the default proof view and unregisters the profiles when dropped, also after a failed +/// assertion. +struct ProofReset([&'static str; 2]); + +impl Drop for ProofReset { + fn drop(&mut self) { + vectorcraft_render::proof::set_view(Default::default()); + for name in self.0 { + cms::unregister_icc(name); + } + } +} + /// A folder of its own for the test `tag`, empty. fn fresh_dir(tag: &str) -> std::path::PathBuf { let dir = vectorcraft_testkit::temp_dir(&format!("color-settings-{tag}")); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 6c0e0c7342..2bc1746ea1 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -3943,6 +3943,42 @@ fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() { } } +/// The text document has no Bunri-Kinshi: a paragraph with it on and an inseparable pair in it is +/// reported as left out, and comes back off; with it off, or with no pair, nothing is reported. +#[test] +fn bunri_kinshi_is_reported_only_for_paragraphs_with_a_pair() { + use vectorcraft_doc::TextObject; + for (text, on, reported) in [ + ("Wait——now", true, true), + ("Wait——now", false, false), + ("Wait—now", true, false), + ("Wait……now", true, true), + ("Wait‥‥now", true, true), + ("あ〳〵い", true, true), + ("あ〴〵い", true, true), + ("あ〵〳い", true, false), + ] { + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK)); + t.para.bunri_kinshi = on; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings); + let mut all = vec![]; + paths(&read_back(&doc).layers, &mut all); + let paras: Vec = all + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.para_at(0).bunri_kinshi), + _ => None, + }) + .collect(); + assert_eq!(paras, [false], "{text} {on}"); + } +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 1d791afadf..1e47ca1564 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -387,6 +387,7 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { } let hangul = text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}')); + let pairs = text.chars().zip(text.chars().skip(1)).any(|(a, b)| vectorcraft_text::inseparable(a, b)); let held = ParaStyle { justify: p.justify, left_indent: p.left_indent, @@ -403,6 +404,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // The text document has no Korean Line Break Anywhere: it reads back off, which // changes only the lines of a paragraph with Hangul in it. korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul, + // Nor Bunri-Kinshi: it reads back off, which changes only the lines of a paragraph + // with an inseparable pair in it. + bunri_kinshi: p.bunri_kinshi && !pairs, ..ParaStyle::default() }; *p != held diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 46fd148db9..3ff736d1ff 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -24,6 +24,8 @@ mod state; mod strips; pub(crate) use paint::collapse; +#[cfg(test)] +pub(crate) use paint::tilings_compared; pub(crate) use state::{GState, Out, Space, process}; pub(crate) use strips::merge_strips; diff --git a/crates/eps/src/import/graphics/paint.rs b/crates/eps/src/import/graphics/paint.rs index fc2490fb41..156b6e421e 100644 --- a/crates/eps/src/import/graphics/paint.rs +++ b/crates/eps/src/import/graphics/paint.rs @@ -5,6 +5,19 @@ use super::{ PsError, REGISTRATION, Rc, Rect, Res, SHADING_SAMPLES, Shared, UNKNOWN_FUNCTIONS, UNKNOWN_SHADINGS, Vec2, clamp_between, ps_err, }; +#[cfg(test)] +thread_local! { + /// How many patterns the lookups of tiling patterns on this thread compared with the pattern + /// they looked for. + static TILINGS_COMPARED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`TILINGS_COMPARED`] of this thread. +#[cfg(test)] +pub(crate) fn tilings_compared() -> u64 { + TILINGS_COMPARED.get() +} + impl Interp<'_> { // ---------- colour ---------- @@ -212,7 +225,13 @@ impl Interp<'_> { let proc = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; let key = proc.items().cloned().ok_or(PsError::Ps("typecheck", "PaintProc".into()))?; let hash = tiling_hash(&key, tile, under.as_ref()); - let made = self.out.tilings.get(&hash).and_then(|list| list.iter().find(|(p, t, u, _)| p.same(&key) && *t == tile && *u == under)); + let made = self.out.tilings.get(&hash).and_then(|list| { + list.iter().find(|(p, t, u, _)| { + #[cfg(test)] + TILINGS_COMPARED.set(TILINGS_COMPARED.get().saturating_add(1)); + p.same(&key) && *t == tile && *u == under + }) + }); if let Some((.., name)) = made { return Ok(Paint::Pattern { pattern: name.clone(), xf }); } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 6a1df810bc..4709a3c5bf 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -7,6 +7,9 @@ use vectorcraft_color::{Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, ColorMode, Document, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; +use crate::import::graphics::{GState, Out, tilings_compared}; +use crate::import::interp::Interp; +use crate::import::obj::Obj; use crate::import::{Dsc, import}; use crate::{EpsOptions, Level, Preview, Raster}; @@ -369,12 +372,19 @@ fn dash_patterns_hold_at_most_65535_entries() { /// Saved and copied graphics states share the dash pattern, and `setdash`, `currentdash` and /// each stroke count the entries they read or copy against the budgets. Under a pattern of -/// 65,535 entries, 4,000,000 passes of `gsave grestore` and 2,000,000 passes of -/// `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and `strokepath` -/// run out of a budget. +/// 65,535 entries, the states that `gsave`, `grestore`, `gstate`, `currentgstate` and `setgstate` +/// save, make or set hold that one pattern, 4,000,000 passes of `gsave grestore` and 2,000,000 +/// passes of `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and +/// `strokepath` run out of a budget. #[test] fn work_on_the_longest_dash_pattern_ends_soon() { let dash = "0 0 10 10 rectfill /a 65535 array def 0 1 65534 { a exch 1 put } for a 0 setdash"; + let shared = run_looking(&format!("{dash} gsave gsave grestore gstate gstate currentgstate dup setgstate"), |it| { + let Some(saved) = it.saved.last().map(|g| g.dash.clone()) else { return false }; + let made: Vec<_> = it.stack.iter().filter_map(|o| if let Obj::GState(g) = o { Some(g.dash.clone()) } else { None }).collect(); + saved.len() == 65_535 && made.len() == 2 && made.iter().chain([&it.g.dash]).all(|m| std::rc::Rc::ptr_eq(m, &saved)) + }); + assert!(shared); for body in ["4000000 { gsave grestore } repeat", "2000000 { gstate currentgstate setgstate } repeat"] { let r = read_soon(&format!("{dash} {body} 20 20 10 10 rectfill")); assert_eq!(objects(&r.document).len(), 2, "{body}: {:?}", r.warnings); @@ -530,13 +540,15 @@ fn non_finite_arithmetic_is_undefinedresult() { assert!(!objects(&r.document).is_empty(), "{:?}", r.warnings); } -/// Tiling patterns and spot inks are found again through a hash of what tells them apart, so a -/// program that makes 200,000 patterns and 200,001 inks reads within [`read_soon`]'s 20 seconds. -/// Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A pattern made -/// again from its prototype is the same pattern, and so is an uncolored pattern set again in a -/// color it was set in before. +/// Tiling patterns are found again through a hash of what tells them apart. A program that sets +/// 200,000 different patterns and also sets 200,001 spot inks reads them all, and its 200,000 +/// pattern lookups compare at most 200,000 earlier patterns in all; going through every pattern +/// made so far would compare about 2 × 10^10. The test does not count or time the spot ink +/// lookups. Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A +/// pattern made again from its prototype is the same pattern, and so is an uncolored pattern set +/// again in a color it was set in before. #[test] -fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { +fn many_tiling_patterns_are_found_again_through_a_hash() { let r = read( "/pp { pop 0 0 1 1 rectfill } def \ /a << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 2 /YStep 2 /PaintProc /pp load >> def \ @@ -560,7 +572,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { assert!(names.iter().all(|n| d.swatch(n).is_some_and(|s| matches!(s.paint, Paint::Pattern { .. })))); // 100,000 patterns whose cells differ, an uncolored pattern in 100,000 tints of an ink that is // white at every tint (only the tint tells those patterns apart), and 200,000 more inks. - let r = read_soon( + let (r, compared) = read_counting_tilings( "0 1 99999 { /i exch def << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep i 2 add /YStep 1 \ /PaintProc { pop 0 0 1 1 rectfill } >> matrix makepattern setpattern } for \ [/Separation (Spot) /DeviceGray { pop 1 }] setcolorspace \ @@ -568,6 +580,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { matrix makepattern def 0 1 99999 { 100000 div p setpattern } for \ 0 1 199999 { /i exch def [/Separation i 8 string cvs /DeviceGray {}] setcolorspace } for 0 0 1 1 rectfill", ); + assert!(compared <= 200_000, "{compared} patterns compared"); let d = &r.document; assert_eq!(d.patterns.len(), 200_000); assert!(d.patterns.iter().enumerate().all(|(i, p)| p.name == format!("Pattern {}", i + 1))); @@ -1112,19 +1125,51 @@ fn hostile_executable_files_and_type_loops_are_refused() { blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor"); } -/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes -/// more than 20 s. +/// How long [`read_soon`] and [`read_counting_tilings`] wait, in seconds: a guard against a read +/// that never ends. The budgets, limits and counts each test names decide what it checks. +const NEVER_ENDS: u64 = 300; + +/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading has not +/// finished after [`NEVER_ENDS`] seconds. fn read_soon(body: &str) -> crate::Imported { - read_within(body, 20) + read_within(body, NEVER_ENDS) } /// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes /// more than `secs` seconds. fn read_within(body: &str, secs: u64) -> crate::Imported { + read_counting(body, secs).0 +} + +/// [`read_soon`], with how many patterns the tiling pattern lookups compared while reading. +fn read_counting_tilings(body: &str) -> (crate::Imported, u64) { + read_counting(body, NEVER_ENDS) +} + +/// [`read_within`], with how many patterns the tiling pattern lookups compared while reading. +fn read_counting(body: &str, secs: u64) -> (crate::Imported, u64) { let (tx, rx) = std::sync::mpsc::channel(); let ps = eps(body); - std::thread::spawn(move || tx.send(import(&ps)).ok()); - rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() + std::thread::spawn(move || tx.send((import(&ps), tilings_compared())).ok()); + let (read, compared) = rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")); + (read.unwrap(), compared) +} + +/// Runs the program of `body` in an interpreter of its own on the 100 × 100 page of [`eps`]'s +/// bounding box, and returns what `look` finds in the interpreter when the program has ended. It +/// does not read the file's comments or make a document. The test fails when the program stops on +/// an error. +fn run_looking(body: &str, look: fn(&Interp) -> T) -> T { + let file = eps(body); + let ps = super::program(&file).unwrap(); + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + let page = vectorcraft_geom::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, Rect::new(0.0, 0.0, 100.0, 100.0))); + assert!(it.run().is_ok(), "running {body} failed"); + let found = look(&it); + it.release(); + found } /// Reads `body` after a square: the square stays, and the program runs out of the operation diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 758fc67716..7865d80668 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -594,7 +594,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel", "Show Panel", "", - "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that can be seen, and shows it again where it was; with the dock hidden by Tab or Shift+Tab, the dock shows again with the panel in front, leaving the toolbar and Control bar as they are; fails and changes nothing in Presentation Mode, which hides every panel", ), ( "window.panel.layout", diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 98cf893dee..26ba5b668b 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -1244,8 +1244,11 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, match o.id { Some(id) => { let mut n = originals.remove(&id).ok_or_else(|| { - let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" }; - out_err(&at, format!("{} {why}", id.0)) + if wanted.contains(&id) { + out_err(&at, format!("{} is listed twice", id.0)) + } else { + out_err(&at, format!("{} is not the id of an input object", id.0)) + } })?; // Type, images and symbol instances are only read: writing one would make it a path. if input_kind(&n).is_some() && !o.kept { diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index c03a6cc0f3..96bddc17f1 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -307,6 +307,36 @@ fn drop_shadow_darkens_outside_the_shape() { assert_eq!(lum(img.pixel(15, 15)), WHITE); } +/// A Transform effect listed after a Drop Shadow transforms the shadow with the art: halving a +/// 40 pt square about its center halves the shadow's 10 pt offset, with Scale Strokes & Effects +/// off or on, and for a group as for a path. +#[test] +fn a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art() { + let r = Rect::new(20.0, 20.0, 60.0, 60.0); + let shadow = ("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"})); + for strokes in [false, true] { + let transform = ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "strokes": strokes})); + let path = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![shadow.clone(), transform.clone()]); + let mut group = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![]); + let l = group.layers[0].id; + let square = group.layers[0].children().and_then(|c| c.first()).cloned().unwrap(); + group.remove(square.id).unwrap(); + let mut g = Node::group(group.alloc_id(), vec![square]); + g.appearance.effects = [shadow.clone(), transform].into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); + group.insert(Some(l), 0, g).unwrap(); + for (what, d) in [("path", path), ("group", group)] { + let img = render(&d); + // The square now covers 30..50 and its shadow 35..55. + assert_eq!(img.pixel(40, 40), [255, 0, 0, 255], "{what}, Scale Strokes & Effects {strokes}"); + assert!(lum(img.pixel(53, 37)) < 100, "{what}, {strokes}: the shadow reaches up to 35: {:?}", img.pixel(53, 37)); + assert_eq!(lum(img.pixel(57, 57)), WHITE, "{what}, {strokes}: the shadow ends at 55"); + // The area redrawn for it takes in the 5 pt shadow around the 30..50 square. + let b = super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap(); + assert!((b.x0 - 25.0).abs() < 1e-9 && (b.x1 - 55.0).abs() < 1e-9, "{what}, {strokes}: {b:?}"); + } + } +} + /// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and /// right; `shade` "color" or "darkness". fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document { diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8eb18626f3..5fed1fb121 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -26,6 +26,7 @@ mod nodes; mod output; mod paint; mod text; +mod variables; use nodes::SymbolDef; pub(crate) use nodes::{MASK_FLAGS, Reuse}; @@ -117,8 +118,10 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: layer_nest: 0, non_latin1_names: false, foreign_ns: BTreeMap::new(), + bound: HashMap::new(), }; w.assign_name_ids(); + w.assign_bound_ids(); for layer in &doc.layers { w.count_colorized_images(layer); } @@ -135,6 +138,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: w.line(""); } + // Include Variable Data, once the body has written the bound objects' ids. + let variables = w.variable_sets(); // XML names cannot use character references. Preserve names that Latin-1 cannot encode. let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names { w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); @@ -207,6 +212,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.indent(1)); out.push_str(&format!("