diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs index cdd0837bd..f05ec58d7 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests_legacy_ai.rs @@ -204,8 +204,8 @@ 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. +/// #2687's file had none. 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(); @@ -244,6 +244,34 @@ fn a_radial_gradient_is_written_with_its_highlight() { } } +/// #2687's radial gradient lists its stops from white at ramp point 0 to #1b2a6b at 100, as +/// §3.5.2's radial definition lists them (10, then 100); the linear one lists them from 100 to 0 +/// (§1.4.3). The reference app read the radial gradient's two stops listed from 100 both at ramp +/// point 100 and drew the circle in #1b2a6b (reported on #2807). +#[test] +fn a_radial_gradient_lists_its_stops_from_the_first() { + 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(); + // Each `%_Bs` line of a definition as its RGB color (0-255) and ramp point. + let listed = |head: &str| -> Vec<([u8; 3], String)> { + let def = text.split(head).nth(1).and_then(|t| t.split("BD\n").next()).unwrap_or_else(|| panic!("no {head:?} in\n{text}")); + def.lines() + .map(|l| { + let n: Vec<&str> = l.split_whitespace().collect(); + let [_, _, _, _, r, g, b, "2", "50", ramp, "%_Bs"] = n.as_slice() else { panic!("{l}") }; + let c = [r, g, b].map(|v| (v.parse::().unwrap() * 255.0).round() as u8); + (c, (*ramp).to_string()) + }) + .collect() + }; + let (white, navy) = ([255, 255, 255], [27, 42, 107]); + assert_eq!(listed("(Gradient) 1 2 Bd\n[\n"), [(white, "0".to_string()), (navy, "100".to_string())], "{text}"); + assert_eq!(listed("(Gradient 2) 0 2 Bd\n[\n"), [(navy, "100".to_string()), (white, "0".to_string())], "{text}"); +} + /// 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 6b400ab05..39fb2f214 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -14,8 +14,8 @@ //! `%AI5_ArtSize` and an empty `%AI3_TemplateBox` at its centre, which puts the ruler origin at //! its bottom-left corner (§2.2), `%AI5_NumLayers`. //! - Setup (§3): `n Bn`, then each gradient: `%AI5_BeginGradient: (name)`, `(name) type n Bd`, -//! `[`, its stops last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, -//! `%AI5_EndGradient`. +//! `[`, its stops (`colorSpec colorStyle midPoint rampPoint %_Bs`), listed from the last for a +//! linear gradient and listed from the first for a radial one, `BD`, `%AI5_EndGradient`. //! - Layers (§9): `%AI5_BeginLayer`, `visible preview enabled printing dimmed 0 colorIndex r g b //! Lb`, `(name) Ln`, the art, `LB`, `%AI5_EndLayer--`. //! - Groups `u` … `U`, clipping groups `q` … `Q` with the clipping path painted `h W n` (§5.6), @@ -338,12 +338,20 @@ end const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops -/// last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, `%AI5_EndGradient`. +/// (`colorSpec colorStyle midPoint rampPoint %_Bs`, each with its midpoint to the next stop), `BD`, +/// `%AI5_EndGradient`. A linear gradient's stops are listed from the last to the first (§1.4.3). A +/// radial gradient's stops are listed from the first to the last, as in §3.5.2's radial +/// definition (10, then 100). fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) { let name = latin_string(name); - let kind = if g.kind == GradientKind::Radial { 1 } else { 0 }; + let radial = g.kind == GradientKind::Radial; + let kind = if radial { 1 } else { 0 }; let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {kind} {} Bd\n[", g.stops.len()); - for stop in g.stops.iter().rev() { + let mut stops: Vec<_> = g.stops.iter().collect(); + if !radial { + stops.reverse(); + } + for stop in stops { let paint = Paint::Solid { color: stop.color, swatch: stop.swatch.clone(), tint: stop.tint }; match w.ink(&paint) { Ink::Gray(v) => { diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index 3644576d6..06d0f9119 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -462,11 +462,10 @@ fn radials() -> Document { } /// 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. +/// center (§3.5.6), 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()); @@ -540,6 +539,66 @@ fn radial_gradients_have_their_highlight() { assert!(near(off.end, 320.0, 240.0) && off.focal.is_some_and(|f| near(f, 300.0, 220.0)), "{off:?}"); } +/// A radial gradient's stops are listed from the first to the last, as §3.5.2's radial definition +/// lists them (10, then 100), and a linear gradient's stops are listed from the last to the first +/// (§1.4.3). Each stop keeps its color and its midpoint to the next stop. The reference app read a +/// radial gradient's stops listed from the last as one color (two stops) or inverted (three +/// stops), reported on #2807. Both gradients read back with their stops. +#[test] +fn radial_gradient_stops_are_listed_from_the_first() { + let white = Color::rgb(1.0, 1.0, 1.0); + let red = Color::rgb(1.0, 0.0, 0.0); + let navy = Color::rgb(27.0 / 255.0, 42.0 / 255.0, 107.0 / 255.0); + let stop = |offset: f32, color: Color, midpoint: f32| GradientStop { midpoint, ..GradientStop::new(offset, color) }; + let stops = vec![stop(0.0, white, 0.3), stop(0.3, red, 0.7), stop(1.0, navy, 0.5)]; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for (kind, x) in [(GradientKind::Radial, 10.0), (GradientKind::Linear, 110.0)] { + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops: stops.clone() }))); + let n = shape(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 80.0, 90.0)), paint, Paint::None); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let out = write(&doc); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + // Each `%_Bs` line of a definition: its RGB color (`c m y k r g b 2`), midpoint and ramp point. + let listed = |head: &str| -> Vec<([f64; 3], f64, f64)> { + let def = text.split(head).nth(1).and_then(|t| t.split("BD\n").next()).unwrap_or_else(|| panic!("no {head:?} in\n{text}")); + def.lines() + .map(|l| { + let n: Vec = l.trim_end_matches(" %_Bs").split_whitespace().map(|v| v.parse().unwrap()).collect(); + let [_, _, _, _, r, g, b, style, mid, ramp] = n.as_slice() else { panic!("{l}") }; + assert_eq!(*style, 2.0, "{l}"); + ([*r, *g, *b], *mid, *ramp) + }) + .collect() + }; + let rgb = |c: Color| c.to_rgb().map(f64::from); + let first_first = [(rgb(white), 30.0, 0.0), (rgb(red), 70.0, 30.0), (rgb(navy), 50.0, 100.0)]; + let mut last_first = first_first; + last_first.reverse(); + let same = |got: &[([f64; 3], f64, f64)], want: &[([f64; 3], f64, f64)]| { + got.len() == want.len() + && got.iter().zip(want).all(|((c, m, r), (wc, wm, wr))| { + c.iter().zip(wc).all(|(a, b)| (a - b).abs() < 1e-3) && (m - wm).abs() < 1e-3 && (r - wr).abs() < 1e-3 + }) + }; + let radial = listed("(Gradient) 1 3 Bd\n[\n"); + assert!(same(&radial, &first_first), "{radial:?}\n{text}"); + let linear = listed("(Gradient 2) 0 3 Bd\n[\n"); + assert!(same(&linear, &last_first), "{linear:?}\n{text}"); + // Read back: both gradients, each stop at its offset with its color and midpoint. + let doc = crate::import(&out.bytes).unwrap().document; + let read: Vec<_> = children(&doc.layers[0]) + .iter() + .filter_map(|n| if let Paint::Gradient(g) = n.appearance.fill_paint() { Some(g.gradient.clone()) } else { None }) + .collect(); + assert_eq!(read.iter().map(|g| g.kind).collect::>(), [GradientKind::Radial, GradientKind::Linear], "{read:#?}"); + for g in &read { + let got: Vec<_> = g.stops.iter().map(|s| (rgb(s.color), f64::from(s.midpoint) * 100.0, f64::from(s.offset) * 100.0)).collect(); + assert!(same(&got, &first_first), "{g:#?}"); + } +} + #[test] fn reads_back_with_layers_groups_and_paths() { let out = write(&sample());