diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs
index aa6f82f08..2ee07857e 100644
--- a/crates/engine/src/tests_legacy_ai.rs
+++ b/crates/engine/src/tests_legacy_ai.rs
@@ -192,6 +192,58 @@ fn art_under_transparent_art_stays_in_its_own_layer() {
assert!(d.layers.iter().all(|l| l.children().is_some_and(|c| c.len() == 1)), "{:?}", d.layers);
}
+/// #2687's file: a circle filled with a radial gradient from white to #1b2a6b, and a square filled
+/// with a linear one.
+const RADIAL_AND_LINEAR: &str = r##""##;
+
+/// A radial gradient is written with its highlight, `0 0 0 0 Bh` at the center (§3.5.5, §3.5.6).
+/// #2687's file had none, and the reference app drew the circle in its outer color. The linear one
+/// has none. Both open again with their type, stops and place.
+#[test]
+fn a_radial_gradient_is_written_with_its_highlight() {
+ let mut s = Session::new();
+ let svg = vectorcraft_format::base64_encode(RADIAL_AND_LINEAR.as_bytes());
+ s.execute("document.open", &json!({"name": "t_radial.svg", "dataBase64": svg})).unwrap();
+ let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
+ let text = String::from_utf8(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
+ assert!(text.contains("\n%AI5_BeginGradient: (Gradient)\n(Gradient) 1 2 Bd\n[\n"), "{text}");
+ assert!(text.contains("\nBb\n0 0 0 0 Bh\n1 (Gradient) 100 100 0 80 1 0 0 1 0 0 Bg\nf\n0 BB\n"), "{text}");
+ assert!(text.contains("\nBb\n1 (Gradient 2) 220 180 0 160 1 0 0 1 0 0 Bg\nf\n0 BB\n"), "{text}");
+ assert_eq!(text.matches(" Bh\n").count(), 1, "{text}");
+ s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
+ let d = &s.doc().unwrap().doc;
+ let mut found = vec![];
+ d.walk(|n| {
+ if let vectorcraft_color::Paint::Gradient(g) = n.appearance.fill_paint() {
+ found.push(*g);
+ }
+ });
+ let [radial, linear] = found.as_slice() else { panic!("{found:#?}") };
+ let navy = vectorcraft_color::Color::rgb(27.0 / 255.0, 42.0 / 255.0, 107.0 / 255.0);
+ use vectorcraft_color::GradientKind::{Linear, Radial};
+ for (g, kind, start, end) in [(radial, Radial, (100.0, 100.0), (180.0, 100.0)), (linear, Linear, (220.0, 20.0), (380.0, 20.0))] {
+ assert_eq!(g.gradient.kind, kind);
+ let [a, b] = g.gradient.stops.as_slice() else { panic!("{g:?}") };
+ assert_eq!((a.offset, b.offset, a.color), (0.0, 1.0, vectorcraft_color::Color::rgb(1.0, 1.0, 1.0)), "{g:?}");
+ assert!(b.color.to_rgb().iter().zip(navy.to_rgb()).all(|(x, y)| (x - y).abs() < 1e-3), "{g:?}");
+ let geom = g.geom.unwrap();
+ assert!((geom.start.x - start.0).abs() < 1e-3 && (geom.start.y - start.1).abs() < 1e-3, "{geom:?}");
+ assert!((geom.end.x - end.0).abs() < 1e-3 && (geom.end.y - end.1).abs() < 1e-3 && geom.focal.is_none(), "{geom:?}");
+ }
+ // White at the center, #1b2a6b at the edge.
+ for ((x, y), want) in [((100.0, 100.0), [255, 255, 255]), ((100.0, 21.0), [27, 42, 107])] {
+ let got = colour_at(d, x, y);
+ assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 8), "{got:?} at {x}, {y}");
+ }
+}
+
/// A `w`×`h` PNG of one colour (`rgba`), 72 ppi.
fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec {
let mut out = vec![];
diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs
index e25871000..9ef02b3c3 100644
--- a/crates/eps/src/legacy_ai.rs
+++ b/crates/eps/src/legacy_ai.rs
@@ -24,9 +24,9 @@
//! corner), painted `N n F f S s B b` (lower case closes first); guides `(N) *` before and after
//! the path (§6); fill rule `XR`; overprint `O`/`R`; stroke `w J j M d`.
//! - Colours (§5.4): `g`/`G` (grey), `k`/`K` (CMYK), `Xa`/`XA` (`r g b`), `x`/`X` (a spot colour
-//! at a tint). Gradient fills (§3.5.3): the path, `Bb`, `1 (name) x y angle length 1 0 0 1 0 0
-//! Bg`, a radial's highlight `x y angle fraction Bh`, `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB`
-//! open) to stroke it too.
+//! at a tint). Gradient fills (§3.5.3): the path, `Bb`, a radial's highlight `x y angle fraction
+//! Bh` (`0 0 0 0 Bh` at the center, §3.5.5, §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`,
+//! `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB` open) to stroke it too.
//! - Images (§8): `%AI5_BeginRaster`, `[matrix] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in
//! hex on comment lines, `%AI5_EndRaster`.
//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx
@@ -996,8 +996,9 @@ impl<'a> Writer<'a> {
// ---------- gradients ----------
/// The definition gradient `g` uses (defined once for each distinct gradient), and its
- /// instance on a path of bounds `bounds`: `Bb`, `1 (name) x y angle length 1 0 0 1 0 0 Bg`, a
- /// radial's highlight `x y angle fraction Bh` (its distance a fraction of the radius, §3.5.5).
+ /// instance on a path of bounds `bounds`: `Bb`, a radial's highlight `x y angle fraction Bh`
+ /// (its distance a fraction of the radius, at most 1, §3.5.5; `0 0 0 0 Bh` at the center,
+ /// §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`.
fn gradient(&mut self, g: &GradientPaint, bounds: Rect) -> String {
let name = self.define(&g.gradient, g.swatch.as_deref());
for stop in &g.gradient.stops {
@@ -1024,15 +1025,14 @@ impl<'a> Writer<'a> {
self.warn(ELLIPTICAL);
}
let mut s = String::from("Bb\n");
- if g.gradient.kind == GradientKind::Radial
- && let Some(f) = geom.focal
- {
- let off: Vec2 = self.to_art * f - start;
- if off.hypot() > 1e-9 && length > 1e-9 {
- let fraction = (off.hypot() / length).min(1.0);
- ps::push_nums(&mut s, &[off.x, off.y, off.y.atan2(off.x).to_degrees(), fraction]);
- s.push_str("Bh\n");
- }
+ if g.gradient.kind == GradientKind::Radial {
+ // Every radial instance has a highlight (§3.5.5), `0 0 0 0 Bh` at the center (§3.5.6).
+ // It lies within the gradient's circle: one past the radius is written on it.
+ let off: Vec2 = geom.focal.map_or(Vec2::ZERO, |f| self.to_art * f - start);
+ let fraction = if off.hypot() > 1e-9 && length > 1e-9 { off.hypot() / length } else { 0.0 };
+ let (off, angle) = if fraction > 0.0 { (off / fraction.max(1.0), off.y.atan2(off.x).to_degrees()) } else { (Vec2::ZERO, 0.0) };
+ ps::push_nums(&mut s, &[off.x, off.y, angle, fraction.min(1.0)]);
+ s.push_str("Bh\n");
}
s.push_str("1 ");
s.push_str(&ps::string(&name));
diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs
index 89517654a..fc9db957f 100644
--- a/crates/eps/src/tests_legacy_ai.rs
+++ b/crates/eps/src/tests_legacy_ai.rs
@@ -89,8 +89,8 @@ fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput {
/// don't print, its type as its glyphs' outlines where the writer writes them (all type with
/// [`LegacyAiOptions::outline_text`], else the type 8-bit text can't hold) and left out where it
/// writes text objects, its
-/// hidden objects shown when `o` writes them visible, and its gradients filled with their first
-/// stop's colour.
+/// hidden objects shown when `o` writes them visible, and its gradient fills and strokes painted
+/// in their first stop's color.
fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
// A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type).
fn outlined(t: &vectorcraft_doc::TextObject) -> Node {
@@ -120,10 +120,12 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
n.visible = true;
}
for item in &mut n.appearance.items {
- if let AppearanceItem::Fill(f) = item
- && let Paint::Gradient(g) = &f.paint
- {
- f.paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color));
+ let paint = match item {
+ AppearanceItem::Fill(f) => &mut f.paint,
+ AppearanceItem::Stroke(s) => &mut s.paint,
+ };
+ if let Paint::Gradient(g) = paint {
+ *paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color));
}
}
if let Some(c) = n.children_mut() {
@@ -294,6 +296,137 @@ fn a_gradient_fill_and_its_stroke_are_one_object() {
assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
+/// A gradient from white to #1b2a6b, the colors of #2687's circle, placed at `geom` (None: fitted
+/// to the object).
+fn white_to_navy(kind: GradientKind, geom: Option) -> Paint {
+ let navy = Color::rgb(27.0 / 255.0, 42.0 / 255.0, 107.0 / 255.0);
+ let g = Gradient { kind, stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, navy)] };
+ Paint::Gradient(Box::new(GradientPaint { geom, ..GradientPaint::new(g) }))
+}
+
+/// A 400 × 400 document: #2687's radial circle (center 100, 100, radius 80) and linear square;
+/// below them a radial square in a group, a radial compound ring, a clipping group whose clipping
+/// path and member are radial, a square stroked radial, and a radial square whose elliptical
+/// gradient has its focal point 30 pt above the center, past its 20 pt radius.
+fn radials() -> Document {
+ let mut doc = Document::new(400.0, 400.0);
+ let layer = doc.layers[0].id;
+ let radial = || white_to_navy(GradientKind::Radial, None);
+ let circle = shape(&mut doc, shapes::ellipse(Rect::new(20.0, 20.0, 180.0, 180.0)), radial(), Paint::None);
+ let square = shape(&mut doc, shapes::rectangle(Rect::new(220.0, 20.0, 380.0, 180.0)), white_to_navy(GradientKind::Linear, None), Paint::None);
+ let member = shape(&mut doc, shapes::rectangle(Rect::new(20.0, 220.0, 60.0, 260.0)), radial(), Paint::None);
+ let group = Node::group(doc.alloc_id(), vec![Arc::new(member)]);
+ let outer = shape(&mut doc, shapes::rectangle(Rect::new(80.0, 220.0, 120.0, 260.0)), Paint::None, Paint::None);
+ let inner = shape(&mut doc, shapes::rectangle(Rect::new(90.0, 230.0, 110.0, 250.0)), Paint::None, Paint::None);
+ let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd });
+ ring.appearance = Appearance::basic(radial(), Paint::None, 0.0).into();
+ let mut clip = Node::new(
+ doc.alloc_id(),
+ NodeKind::Path {
+ path: shapes::rectangle(Rect::new(140.0, 220.0, 180.0, 260.0)),
+ rule: FillRule::NonZero,
+ live: None,
+ clipping: true,
+ guide: false,
+ },
+ );
+ clip.appearance = Appearance::basic(radial(), Paint::None, 0.0).into();
+ let clipped = shape(&mut doc, shapes::rectangle(Rect::new(130.0, 210.0, 190.0, 270.0)), radial(), Paint::None);
+ let window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true });
+ let stroked = shape(&mut doc, shapes::rectangle(Rect::new(200.0, 220.0, 240.0, 260.0)), Paint::None, radial());
+ let geom = vectorcraft_color::GradientGeom {
+ start: vectorcraft_geom::Point::new(300.0, 240.0),
+ end: vectorcraft_geom::Point::new(320.0, 240.0),
+ aspect: 2.0,
+ focal: Some(vectorcraft_geom::Point::new(300.0, 210.0)),
+ };
+ let off_center =
+ shape(&mut doc, shapes::rectangle(Rect::new(260.0, 220.0, 340.0, 260.0)), white_to_navy(GradientKind::Radial, Some(geom)), Paint::None);
+ for n in [circle, square, group, ring, window, stroked, off_center] {
+ doc.insert(Some(layer), usize::MAX, n).unwrap();
+ }
+ doc
+}
+
+/// Every radial gradient instance has its highlight `Bh` (§3.5.5): `0 0 0 0 Bh` when it is at the
+/// center (#2687: without it the reference app drew the circle in its outer color), on fills,
+/// strokes, in groups, compound paths and clipping groups, and an off-center one within the
+/// gradient's circle. Linear instances have none. VectorCraft reads back one linear and at least
+/// seven radial gradients, each with its stops, and the circle, the square and the off-center
+/// square in their place.
+#[test]
+fn radial_gradients_have_their_highlight() {
+ let out = write(&radials());
+ let text = String::from_utf8(out.bytes.clone()).unwrap();
+ // One definition for the radial gradient, one for the linear one (§3.5.2).
+ assert!(text.contains("2 Bn\n%AI5_BeginGradient: (Gradient)\n(Gradient) 1 2 Bd\n[\n"), "{text}");
+ assert!(text.contains("%AI5_BeginGradient: (Gradient 2)\n(Gradient 2) 0 2 Bd\n[\n"), "{text}");
+ // Each instance, `Bb` to `BB`.
+ let instances: Vec<&str> = text.split("Bb\n").skip(1).filter_map(|t| t.split_inclusive("BB\n").next()).collect();
+ let radial: Vec<&str> = instances.iter().copied().filter(|i| i.contains(" (Gradient) ")).collect();
+ let linear: Vec<&str> = instances.iter().copied().filter(|i| i.contains(" (Gradient 2) ")).collect();
+ assert_eq!((radial.len() + linear.len(), linear.len()), (instances.len(), 1), "{instances:#?}");
+ assert!(radial.len() >= 8, "{radial:#?}");
+ for i in &radial {
+ let bh = i.lines().next().unwrap_or_default();
+ assert!(bh.ends_with(" Bh") && bh.split_whitespace().count() == 5, "{i}");
+ }
+ assert!(!linear[0].contains("Bh"), "{}", linear[0]);
+ for needle in [
+ // #2687's circle and square.
+ "\nBb\n0 0 0 0 Bh\n1 (Gradient) 100 300 0 80 1 0 0 1 0 0 Bg\nf\n0 BB\n",
+ "\nBb\n1 (Gradient 2) 220 300 0 160 1 0 0 1 0 0 Bg\nf\n0 BB\n",
+ // In a group.
+ "\nBb\n0 0 0 0 Bh\n1 (Gradient) 40 160 0 20 1 0 0 1 0 0 Bg\nf\n0 BB\nU\n",
+ // Both subpaths of the compound ring (§5.3.5).
+ "\nBb\n0 0 0 0 Bh\n1 (Gradient) 100 160 0 20 1 0 0 1 0 0 Bg\nf\n0 BB\n90 170 m\n",
+ "\nBb\n0 0 0 0 Bh\n1 (Gradient) 100 160 0 20 1 0 0 1 0 0 Bg\nf\n0 BB\n*U\n",
+ // The clipping path's fill and the clipped square (§5.6.2).
+ "\nBb\n0 0 0 0 Bh\n1 (Gradient) 160 160 0 20 1 0 0 1 0 0 Bg\nf\n0 BB\n",
+ "\nBb\n0 0 0 0 Bh\n1 (Gradient) 160 160 0 30 1 0 0 1 0 0 Bg\nf\n0 BB\nQ\n",
+ // The stroke, written as its outline filled.
+ "\nBb\n0 0 0 0 Bh\n1 (Gradient) 220 160 0 21 1 0 0 1 0 0 Bg\nf\n0 BB\n",
+ // The focal point 30 pt up, 1.5 radii out: written on the circle, straight up.
+ "\nBb\n0 20 90 1 Bh\n1 (Gradient) 300 160 0 20 1 0 0 1 0 0 Bg\nf\n0 BB\n",
+ ] {
+ assert!(text.contains(needle), "missing {needle:?} in\n{text}");
+ }
+ for expected in [STROKES, ELLIPTICAL] {
+ assert!(out.warnings.iter().any(|w| w == expected), "{expected} not in {:?}", out.warnings);
+ }
+ // Read back: every gradient keeps its stops, the document holds the linear square and at
+ // least seven radial gradients, and the circle, the square and the off-center square keep
+ // their place.
+ let doc = crate::import(&out.bytes).unwrap().document;
+ let mut found = vec![];
+ doc.walk(|n| {
+ for i in &n.appearance.items {
+ if let AppearanceItem::Fill(f) = i
+ && let Paint::Gradient(g) = &f.paint
+ {
+ found.push((**g).clone());
+ }
+ }
+ });
+ let kinds = |k: GradientKind| found.iter().filter(|g| g.gradient.kind == k).count();
+ // Seven radial objects (a compound path's members carry its fill too) and the linear square.
+ assert!(kinds(GradientKind::Radial) >= 7 && kinds(GradientKind::Linear) == 1, "{found:#?}");
+ let near = |a: vectorcraft_geom::Point, x: f64, y: f64| (a.x - x).abs() < 1e-3 && (a.y - y).abs() < 1e-3;
+ for g in &found {
+ let [white, navy] = g.gradient.stops.as_slice() else { panic!("{g:?}") };
+ assert_eq!((white.offset, navy.offset, white.color), (0.0, 1.0, Color::rgb(1.0, 1.0, 1.0)), "{g:?}");
+ let rgb = navy.color.to_rgb();
+ assert!([27.0, 42.0, 107.0].iter().zip(rgb).all(|(want, got)| (want / 255.0 - got).abs() < 1e-3), "{rgb:?}");
+ }
+ let placed = |x: f64, y: f64| found.iter().filter_map(|g| g.geom.map(|m| (g.gradient.kind, m))).find(|(_, m)| near(m.start, x, y));
+ let Some((GradientKind::Radial, circle)) = placed(100.0, 100.0) else { panic!("{found:#?}") };
+ assert!(near(circle.end, 180.0, 100.0) && circle.focal.is_none(), "{circle:?}");
+ let Some((GradientKind::Linear, square)) = placed(220.0, 100.0) else { panic!("{found:#?}") };
+ assert!(near(square.end, 380.0, 100.0), "{square:?}");
+ let Some((GradientKind::Radial, off)) = placed(300.0, 240.0) else { panic!("{found:#?}") };
+ assert!(near(off.end, 320.0, 240.0) && off.focal.is_some_and(|f| near(f, 300.0, 220.0)), "{off:?}");
+}
+
#[test]
fn reads_back_with_layers_groups_and_paths() {
let out = write(&sample());