diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d36fce90..ef80359e 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1741,6 +1741,7 @@ fn editing_text() -> String { "U".into(), "0 Xw".into(), "1 0 0 0 1 0 Bg".into(), + "Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n1 0 0 1 0 0 Xm\n0 0 0 0 0 0 Bm\n1 0 0 1 5 5 Bm\n0 BB".into(), "0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(), "0 0 0 1 (Black global) 0.4 0 Xk".into(), "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(), diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 783e7e44..c0f25726 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -9,8 +9,12 @@ //! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6] //! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`. //! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d -//! tx ty Bg`, the gradient matrix `a b c d tx ty Bm`, the focal point `x y angle length Bh`, -//! `flag BB`. +//! tx ty Bg`, the focal point `x y angle length Bh`, `flag BB`. Newer files write the gradient's +//! place as matrices after `Bg` (whose own numbers are then not in art space): `a b c d tx ty Xm` +//! takes the unit vector onto a linear gradient's whole ramp (0 to 100, its angle and length in +//! it), and `a b c d tx ty Bm` takes it onto the part between two stops next to each other, one +//! `Bm` for each pair in the order of the ramp (`Bc`, around them, are the caps past the first and +//! last stops). A radial gradient has one `Bm`, which takes the unit circle onto it. use std::collections::HashMap; @@ -166,8 +170,10 @@ pub(super) struct Instance { length: f64, /// `Bg`'s own matrix. bg: Affine, - /// The gradient matrix (`Bm`), when there is one. - bm: Option, + /// The matrix of a linear gradient's whole ramp (`Xm`), when there is one. + xm: Option, + /// The first and the last gradient matrix (`Bm`), when there are any. + bm: Option<(Affine, Affine)>, /// The focal point's offset from the origin (`Bh`). hilight: Vec2, } @@ -197,10 +203,22 @@ impl Instance { } } - /// `a b c d tx ty Bm`. - pub fn bm(&mut self, vals: &[V]) { + /// The matrix of `a b c d tx ty Bm` or `Xm`, when it can be inverted. + fn matrix(vals: &[V]) -> Option { let n: Vec = vals.iter().filter_map(V::num).collect(); - self.bm = n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12); + n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12) + } + + /// `a b c d tx ty Bm`: one for each pair of stops next to each other. + pub fn bm(&mut self, vals: &[V]) { + if let Some(m) = Self::matrix(vals) { + self.bm = Some((self.bm.map_or(m, |(first, _)| first), m)); + } + } + + /// `a b c d tx ty Xm`. + pub fn xm(&mut self, vals: &[V]) { + self.xm = Self::matrix(vals); } /// `x y angle length Bh`. @@ -215,19 +233,35 @@ impl Instance { pub fn paint(&self, defs: &HashMap, to_doc: Affine) -> Option { let name = self.name.as_ref()?; let gradient = defs.get(name)?.clone(); - let m = to_doc * self.bm.unwrap_or(Affine::IDENTITY) * self.bg; - let a = self.angle.to_radians(); - let dir = Vec2::new(a.cos(), a.sin()) * self.length; - let start = m * self.origin; - let end = m * (self.origin + dir); + let radial = gradient.kind == GradientKind::Radial; + // The unit vector through `m`, the point across it (for an elliptical radial gradient) and + // the focal point. + let unit = |m: Affine| (m * Point::ORIGIN, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0), m * self.hilight.to_point()); + let whole = if radial { self.bm.map(|(m, _)| m).or(self.xm) } else { self.xm }; + let (start, end, across, focal) = match (whole, self.bm) { + (Some(m), _) => unit(to_doc * m), + // A linear gradient's `Bm`s alone: they run from its first stop to its last. + (None, Some((first, last))) => { + let (a, b) = (to_doc * first * Point::ORIGIN, to_doc * last * Point::new(1.0, 0.0)); + let from = gradient.stops.first().map_or(0.0, |s| f64::from(s.offset)); + let to = gradient.stops.last().map_or(1.0, |s| f64::from(s.offset)); + let whole = (b - a) / (to - from).max(1e-6); + let start = a - whole * from; + (start, start + whole, start, start) + } + // `Bg`'s own numbers, in art space through its matrix. + (None, None) => { + let m = to_doc * self.bg; + let a = self.angle.to_radians(); + let dir = Vec2::new(a.cos(), a.sin()) * self.length; + (m * self.origin, m * (self.origin + dir), m * (self.origin + Vec2::new(-dir.y, dir.x)), m * (self.origin + self.hilight)) + } + }; if !(start.is_finite() && end.is_finite()) || start.distance(end) < 1e-9 { return None; } - // Across the vector, for an elliptical radial gradient. - let across = m * (self.origin + Vec2::new(-dir.y, dir.x)); let aspect = (across.distance(start) / start.distance(end)).clamp(0.01, 100.0); - let focal = (self.hilight.hypot() > 1e-9).then(|| m * (self.origin + self.hilight)).filter(|p| p.is_finite()); - let radial = gradient.kind == GradientKind::Radial; + let focal = (self.hilight.hypot() > 1e-9).then_some(focal).filter(|p| p.is_finite()); let geom = GradientGeom { start, end, aspect: if radial { aspect } else { 1.0 }, focal: if radial { focal } else { None } }; let angle = -(end - start).atan2().to_degrees(); Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch: Some(name.clone()), ..GradientPaint::new(gradient) }))) @@ -308,4 +342,35 @@ mod tests { assert!((geom.aspect - 1.0).abs() < 1e-9); assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none()); } + + /// A linear gradient of three stops as newer files place it: `Bg` with numbers that aren't in + /// art space, `Xm` for the whole ramp and a `Bm` for each pair of stops. + #[test] + fn a_linear_gradient_runs_its_whole_ramp_not_its_last_part() { + let mut d = GradientDef::begin(&[V::Str(b"L".to_vec()), V::Num(0.0), V::Num(3.0)]).unwrap(); + for ramp in [100.0, 60.0, 20.0] { + d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut vec![]); + } + let defs = HashMap::from([d.finish().unwrap()]); + let instance = |xm: bool| { + let mut i = Instance::begin(&[]); + let mut bg = vec![V::Num(1.0), V::Str(b"L".to_vec())]; + bg.extend(nums(&[0.3, -0.2, -90.0, 0.5, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0])); + i.bg(&bg); + if xm { + i.xm(&nums(&[0.0, -200.0, -30.0, 0.0, 10.0, 300.0])); + } + // From the stop at 20 to the one at 60, then on to the one at 100. + i.bm(&nums(&[0.0, -80.0, -30.0, 0.0, 10.0, 260.0])); + i.bm(&nums(&[0.0, -80.0, -30.0, 0.0, 10.0, 180.0])); + let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() }; + gp.geom.unwrap() + }; + for xm in [true, false] { + let geom = instance(xm); + // (The stops' offsets are `f32`.) + assert!(geom.start.distance(Point::new(10.0, 300.0)) < 1e-5 && geom.end.distance(Point::new(10.0, 100.0)) < 1e-5, "{xm}: {geom:?}"); + assert_eq!((geom.aspect, geom.focal), (1.0, None)); + } + } } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index e82a534d..83362f48 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -338,7 +338,6 @@ const IGNORED: &[&str] = &[ "X=", "X+", "Bc", - "Xm", "XP", "Np", // Flatness (`i`): how finely curves print. @@ -921,13 +920,22 @@ impl<'a> Reader<'a> { i.bm(&vals); } } + "Xm" => { + if let Some(i) = self.instance.as_mut() { + i.xm(&vals); + } + } "Bh" => { if let Some(i) = self.instance.as_mut() { i.bh(&vals); } } "BB" => { - if let Some(i) = self.instance.take() { + // An instance that closes with no path to paint is an object's own note of its + // gradient, written after it: it paints nothing (not the next path either). + if let Some(i) = self.instance.take() + && !self.path.elements().is_empty() + { self.set_instance(&i); } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 3a468d8c..596aee25 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -281,6 +281,23 @@ fn gradients() { assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0))); // The gradient was the one object's. assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); + // A linear gradient with a stop in the middle: `Xm` places its whole ramp, a `Bm` each of its + // two parts. The object's own note of its gradient after it paints nothing. + let defs = "(L) 0 3 Bd\n%_1 0 50 100 Bs\n%_0.5 0 50 40 Bs\n%_0 0 50 0 Bs\nBD\n"; + let art_ = format!( + "{RED}{}Bb\n1 (L) 0 0 -90 1 1 0 0 1 0 0 1 Bg\n0 -100 -100 0 150 100 Xm\n0 -9000 -100 0 150 9100 Bc\n0 -40 -100 0 150 100 Bm\n\ + 0 -60 -100 0 150 60 Bm\n0 -9000 -100 0 150 0 Bc\nf\n0 BB\n0 1 Xd\nBb\n2 (L) 0 0 -90 1 1 0 0 1 0 0 1 Bg\n0 BB\n6 () XW\n{}f\n", + rect_path(50.0, 0.0, 150.0, 100.0), + rect_path(0.0, 0.0, 10.0, 10.0), + ); + let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc; + let a = art(&doc); + let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) }; + assert_eq!(g.gradient.stops.len(), 3); + // Art space has y up from the crop marks' bottom. + let geom = g.geom.unwrap(); + assert!(geom.start.distance(Point::new(150.0, 0.0)) < 1e-9 && geom.end.distance(Point::new(150.0, 100.0)) < 1e-9, "{geom:?}"); + assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "the note after the object isn't the next path's paint"); } #[test]