mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Merge pull request #2807 from dotCooCoo/fix/issue-2687
Legacy .ai export: radial gradients are written with their highlight (Bh)
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