Merge pull request #3285 from dotCooCoo/fix/legacy-ai-radial-gradient-stop-order

Legacy .ai export: a radial gradient's color stops are written from ramp point 0 to 100
This commit is contained in:
BFlat
2026-10-21 15:41:38 +07:00
committed by GitHub
3 changed files with 107 additions and 12 deletions
+30 -2
View File
@@ -204,8 +204,8 @@ const RADIAL_AND_LINEAR: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" widt
</svg>"##;
/// 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::<f64>().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<u8> {
let mut out = vec![];
+13 -5
View File
@@ -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) => {
+64 -5
View File
@@ -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<f64> = 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::<Vec<_>>(), [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());