mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Legacy .ai export: radial gradients are written with their highlight (Bh)
The version 7 .ai writer wrote a radial gradient's highlight (Bh) only when the gradient had a focal point off its center. A centered radial gradient, such as an SVG radialGradient without fx and fy, was written Bb, Bg, f, BB with no Bh. Every radial instance in the Adobe Illustrator File Format Specification version 7.0, which the writer follows, has one, 0 0 0 0 Bh for a centered highlight (sections 3.5.5, 3.5.6 and 9.3). #2687 reports that the reference app draws such a circle in its outer color. The writer now writes Bh in every radial instance, 0 0 0 0 Bh at the center. A focal point past the radius, which an elliptical gradient written circular can have, is written on the circle, so its x and y offsets agree with the fraction 1 written after them (section 3.5.5). Linear instances are unchanged. The result was not checked in the reference app.
This commit is contained in:
@@ -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##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200" viewBox="0 0 400 200">
|
||||
<defs>
|
||||
<radialGradient id="r" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#1b2a6b"/></radialGradient>
|
||||
<linearGradient id="l" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#1b2a6b"/></linearGradient>
|
||||
</defs>
|
||||
<circle cx="100" cy="100" r="80" fill="url(#r)"/>
|
||||
<rect x="220" y="20" width="160" height="160" fill="url(#l)"/>
|
||||
</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.
|
||||
#[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<u8> {
|
||||
let mut out = vec![];
|
||||
|
||||
+14
-14
@@ -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));
|
||||
|
||||
@@ -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<vectorcraft_color::GradientGeom>) -> 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());
|
||||
|
||||
Reference in New Issue
Block a user