Merge pull request #1390 from storytold/fix/1264-ai-gradient

.ai import: a gradient with stops in the middle runs its whole ramp (follow-up to #1264)
This commit is contained in:
BFlat 2026-10-12 18:51:49 +07:00 committed by GitHub
commit b185fbbe45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 109 additions and 18 deletions

View File

@ -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(),

View File

@ -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<Affine>,
/// The matrix of a linear gradient's whole ramp (`Xm`), when there is one.
xm: Option<Affine>,
/// 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<Affine> {
let n: Vec<f64> = 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<String, Gradient>, to_doc: Affine) -> Option<Paint> {
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));
}
}
}

View File

@ -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);
}
}

View File

@ -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]