Legacy .ai export: a radial gradient's color stops are written from ramp point 0 to 100

The version 7 .ai writer wrote the %_Bs color stops of every gradient
from ramp point 100 down to 0. The Adobe Illustrator File Format
Specification version 7.0 lists a linear gradient's stops in that order
(section 1.4.3). Its radial definition, "White & Purple Radial", lists
them from 10 to 100 (section 3.5.2). As reported on #2807, the
reference app reads both stops of #2687's radial gradient, written from
100 down to 0, at ramp point 100 and draws the circle in #1b2a6b. It
reads a radial gradient with three stops written that way in reverse
order.

The writer now lists a radial gradient's stops from the first to the
last. Each stop is written with its own midpoint to the next stop, and
section 3.5.2 says the last stop's midpoint is ignored. Linear
gradients are unchanged.
This commit is contained in:
dotCooCoo
2026-10-21 15:20:18 +07:00
committed by bflatastic
parent 9ce5ffd244
commit bda3c14f72
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());