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());