From 81307f82743be86ada428f388eaa175172b7f2eb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:23:23 -0500 Subject: [PATCH] Add craft-vectordrawable: Android Vector Drawable writer (#874) The format half of FigCraft's crates/vectordrawable (at 24cf7032): the model of paths, groups, clips and gradients, the XML writer, and the mapping of a gradient on an affine frame to what a drawable can say. The walk over FigCraft's document, its compatibility modes and warnings stay in FigCraft. Part of #778. --- Cargo.lock | 8 + Cargo.toml | 1 + README.md | 4 + crates/vectordrawable/Cargo.toml | 17 ++ crates/vectordrawable/src/lib.rs | 23 ++ crates/vectordrawable/src/model.rs | 147 ++++++++++ crates/vectordrawable/src/paint.rs | 280 ++++++++++++++++++ crates/vectordrawable/src/write.rs | 447 +++++++++++++++++++++++++++++ 8 files changed, 927 insertions(+) create mode 100644 crates/vectordrawable/Cargo.toml create mode 100644 crates/vectordrawable/src/lib.rs create mode 100644 crates/vectordrawable/src/model.rs create mode 100644 crates/vectordrawable/src/paint.rs create mode 100644 crates/vectordrawable/src/write.rs diff --git a/Cargo.lock b/Cargo.lock index 7380a3ac8..8d89b3199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1320,6 +1320,14 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-vectordrawable" +version = "0.1.0" +dependencies = [ + "craft-xml", + "kurbo 0.13.1", +] + [[package]] name = "craft-video" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 1ef51c7c1..2a36efc1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ craft-tiff = { path = "crates/tiff" } craft-ui = { path = "crates/ui" } craft-update = { path = "crates/update" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } +craft-vectordrawable = { path = "crates/vectordrawable" } craft-xml = { path = "crates/xml" } craft-video = { path = "crates/video" } craft-web-api = { path = "crates/web-api" } diff --git a/README.md b/README.md index 508c5ecb9..831c1a9d6 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` shape (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, `screenshot`) over the app's JSON control channel. diff --git a/crates/vectordrawable/Cargo.toml b/crates/vectordrawable/Cargo.toml new file mode 100644 index 000000000..edd1839d2 --- /dev/null +++ b/crates/vectordrawable/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "craft-vectordrawable" +description = "Android Vector Drawable writer: a model of paths, groups, clips and gradients, the XML Android Studio formats, and the gradient mapping a drawable needs" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +kurbo = { workspace = true } + +[dev-dependencies] +craft-xml = { workspace = true } diff --git a/crates/vectordrawable/src/lib.rs b/crates/vectordrawable/src/lib.rs new file mode 100644 index 000000000..087afb888 --- /dev/null +++ b/crates/vectordrawable/src/lib.rs @@ -0,0 +1,23 @@ +//! Android Vector Drawable: a `` XML drawable written from a small model. +//! +//! Written from Android's public `VectorDrawable` documentation (elements ``, ``, +//! ``, ``, gradients as inline `GradientColor` resources through `aapt:attr`). +//! +//! - [`model`]: what a drawable holds, in viewport coordinates: paths with a solid or gradient +//! fill and a stroke Android draws itself, and groups with a scale-rotate-translate transform +//! and a clip outline. +//! - [`write`]: the XML ([`write::document`]), with its numbers, colours, path data and escaping. +//! - [`paint`]: fitting a gradient drawn through any affine frame to what a drawable can say: a +//! linear gradient's end point projected, radial and sweep gradients in a `` that maps +//! the unit circle, a sweep's stops turned. +//! +//! A drawable has no images, patterns, effects, blend modes, masks, dashes or layer opacity, a +//! `` can only scale, rotate and translate, and a `` has no transform at all. What +//! the caller's document has of those is the caller's to bake in, approximate or leave out +//! before it builds the model. There is no reader. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod model; +pub mod paint; +pub mod write; diff --git a/crates/vectordrawable/src/model.rs b/crates/vectordrawable/src/model.rs new file mode 100644 index 000000000..a405e14e8 --- /dev/null +++ b/crates/vectordrawable/src/model.rs @@ -0,0 +1,147 @@ +//! What a drawable holds before it's written: paths and groups in viewport coordinates. + +use kurbo::{BezPath, Rect}; + +/// Straight (not premultiplied) sRGB red, green, blue and alpha, each 0..=1. Written as 8 bits a +/// component; out-of-range and non-finite components are clamped when written. +pub type Color = [f32; 4]; + +/// A gradient's geometry in the coordinates of the path it fills. +#[derive(Clone, Debug, PartialEq)] +pub enum GradientKind { + Linear { start: (f64, f64), end: (f64, f64) }, + Radial { center: (f64, f64), radius: f64 }, + Sweep { center: (f64, f64) }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Gradient { + pub kind: GradientKind, + /// Offsets in 0..=1, ascending; colours with their opacity folded in. + pub stops: Vec<(f64, Color)>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Fill { + Solid(Color), + Gradient(Gradient), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Cap { + #[default] + Butt, + Round, + Square, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Join { + #[default] + Miter, + Round, + Bevel, +} + +/// A stroke Android draws itself: centred, one width, one solid colour, no dashes. +#[derive(Clone, Debug, PartialEq)] +pub struct Stroke { + pub color: Color, + pub width: f64, + pub cap: Cap, + pub join: Join, + /// `strokeMiterLimit`; Android's default is 4. + pub miter: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Path { + /// `android:name`. + pub name: Option, + pub data: BezPath, + pub fill: Option, + pub stroke: Option, + /// `fillType="evenOdd"` (API 24); non-zero otherwise. + pub even_odd: bool, +} + +/// A ``'s own transform: scale, then rotate (degrees), then translate. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroupTransform { + pub translate: (f64, f64), + pub rotation: f64, + pub scale: (f64, f64), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Group { + /// `android:name`. + pub name: Option, + pub transform: Option, + /// A non-zero clip outline in the group's (untransformed) coordinates. + pub clip: Option, + pub children: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ItemKind { + Path(Path), + Group(Group), +} + +/// One written element with the viewport area it paints (for [`Item::overlap`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Item { + pub kind: ItemKind, + pub bbox: Rect, +} + +impl Item { + /// The areas of what paints, each a leaf (a path, or a transformed group). + pub fn leaf_boxes(items: &[Item], out: &mut Vec) { + for it in items { + match &it.kind { + ItemKind::Group(g) if g.transform.is_none() => Self::leaf_boxes(&g.children, out), + _ => out.push(it.bbox), + } + } + } + + /// Multiply every colour's alpha by `k`: a layer's opacity folded into what it paints, since + /// a drawable has no group opacity. The same picture only when the leaves don't overlap + /// ([`Item::overlap`]). + pub fn scale_alpha(items: &mut [Item], k: f32) { + let fade = |c: &mut Color| c[3] = (c[3] * k).clamp(0.0, 1.0); + for it in items { + match &mut it.kind { + ItemKind::Path(p) => { + match &mut p.fill { + Some(Fill::Solid(c)) => fade(c), + Some(Fill::Gradient(g)) => g.stops.iter_mut().for_each(|(_, c)| fade(c)), + None => {} + } + if let Some(s) = &mut p.stroke { + fade(&mut s.color); + } + } + ItemKind::Group(g) => Self::scale_alpha(&mut g.children, k), + } + } + } + + /// Whether any two leaves' areas overlap (more than 64 leaves count as overlapping). + pub fn overlap(items: &[Item]) -> bool { + const MAX: usize = 64; + let mut boxes = Vec::new(); + Self::leaf_boxes(items, &mut boxes); + if boxes.len() > MAX { + return true; + } + boxes.iter().enumerate().any(|(i, a)| { + boxes.iter().skip(i + 1).any(|b| { + let r = a.intersect(*b); + r.width() > 1e-9 && r.height() > 1e-9 + }) + }) + } +} diff --git a/crates/vectordrawable/src/paint.rs b/crates/vectordrawable/src/paint.rs new file mode 100644 index 000000000..44dacccf4 --- /dev/null +++ b/crates/vectordrawable/src/paint.rs @@ -0,0 +1,280 @@ +//! Gradients in Vector Drawable terms. +//! +//! A gradient is given on a frame `F`, an affine map of the unit box into the viewport: (0,0) is +//! where it starts, (1,0) where it ends and (0,1) its width handle. A linear gradient bands +//! along `F`'s second axis, so its Vector Drawable end point is the first axis with the part +//! along the second removed: exact for any frame, skewed too. Radial and sweep gradients live on +//! the unit circle: `F = T · R(φ) · S(sx, sy) · R(θ)` (a 2×2 singular value decomposition; `sy` +//! is negative for a mirrored frame), and a circle doesn't change under the last rotation, so a +//! `` with translate `T`, rotation `φ` and scale `S` holds a unit gradient at the origin +//! and the path mapped back by the group's inverse. A sweep turns with `θ`: its stops are +//! shifted by `θ / 2π` instead. + +use kurbo::{Affine, Vec2}; + +use crate::model::{Color, Gradient, GradientKind, GroupTransform}; + +/// The shape of a gradient on its frame ([`place`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Shape { + /// From (0,0) to (1,0), banded along the second axis. + Linear, + /// The unit circle around (0,0). + Radial, + /// Around (0,0), starting along the first axis. + Sweep, +} + +/// A gradient fill, ready to write. +#[derive(Clone, Debug, PartialEq)] +pub enum Placed { + /// In viewport coordinates, on the path as it is. + Plain(Gradient), + /// In the coordinates of a `` with this transform; `inverse` maps the path into them. + Grouped { transform: GroupTransform, inverse: Affine, gradient: Gradient }, + /// One stop: a solid colour. + Solid(Color), + /// No stops, or a frame that collapses to a line or a point: nothing is painted. + Nothing, +} + +/// `m` (2×2, column vectors `(a, b)` and `(c, d)`) as `R(φ) · diag(sx, sy) · R(θ)`: `(φ, sx, +/// sy, θ)` in radians. `sx ≥ |sy|`; `sy < 0` when `m` mirrors. +pub fn svd(a: f64, b: f64, c: f64, d: f64) -> (f64, f64, f64, f64) { + let (e, f, g, h) = ((a + d) / 2.0, (a - d) / 2.0, (b + c) / 2.0, (b - c) / 2.0); + let (q, r) = (e.hypot(h), f.hypot(g)); + let (a1, a2) = (g.atan2(f), h.atan2(e)); + ((a2 + a1) / 2.0, q + r, q - r, (a2 - a1) / 2.0) +} + +/// The colour of `stops` (sorted, at least one) at `t`, clamped at the ends. +pub fn color_at(stops: &[(f64, Color)], t: f64) -> Color { + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; 4] }; + if t <= first.0 { + return first.1; + } + for w in stops.windows(2) { + if let [(o0, c0), (o1, c1)] = w + && t <= *o1 + { + let span = o1 - o0; + let k = if span > 0.0 { ((t - o0) / span) as f32 } else { 1.0 }; + return [0, 1, 2, 3].map(|i| c0[i] + (c1[i] - c0[i]) * k); + } + } + last.1 +} + +/// Sweep stops turned by `shift` of a turn: what was at `t` is at `t + shift` (wrapping), with +/// the seam where the gradient starts over kept as a hard step. +pub fn turn_stops(stops: &[(f64, Color)], shift: f64) -> Vec<(f64, Color)> { + let d = shift.rem_euclid(1.0); + if !d.is_finite() || !(1e-9..=1.0 - 1e-9).contains(&d) || stops.is_empty() { + return stops.to_vec(); + } + let seam = 1.0 - d; + let mut out = vec![(0.0, color_at(stops, seam))]; + out.extend(stops.iter().filter(|(o, _)| *o > seam).map(|(o, c)| (o - seam, *c))); + out.push((d, color_at(stops, 1.0))); + out.push((d, color_at(stops, 0.0))); + out.extend(stops.iter().filter(|(o, _)| *o < seam).map(|(o, c)| (o + d, *c))); + out.push((1.0, color_at(stops, seam))); + // A stop on the seam itself would repeat at the same offset: keep one of each. + out.dedup_by(|b, a| (a.0 - b.0).abs() < 1e-9 && a.1 == b.1); + out +} + +/// Whether `xf` keeps angles and scales evenly (a rotation, flip and uniform scale), so a stroke +/// width stays one width and Android can draw the stroke itself. +pub fn similarity(xf: Affine) -> bool { + let [a, b, c, d, _, _] = xf.as_coeffs(); + let s2 = (a * a + b * b + c * c + d * d) / 2.0; + s2.is_finite() && s2 > 0.0 && (a * c + b * d).abs() <= 1e-6 * s2 && (a * a + b * b - c * c - d * d).abs() <= 1e-6 * s2 +} + +/// A gradient of `shape` with `stops` (offsets ascending in 0..=1, opacity folded into the +/// colours) on `frame`, the unit box mapped into the viewport, as a drawable can say it. +pub fn place(shape: Shape, stops: Vec<(f64, Color)>, frame: Affine) -> Placed { + match stops.as_slice() { + [] => return Placed::Nothing, + [(_, c)] => return Placed::Solid(*c), + _ => {} + } + let [a, b, c, d, e, ff] = frame.as_coeffs(); + let det = a * d - b * c; + let scale = (a * a + b * b + c * c + d * d).max(f64::MIN_POSITIVE); + if !(det.is_finite() && e.is_finite() && ff.is_finite()) || det.abs() <= 1e-12 * scale { + return Placed::Nothing; + } + if shape == Shape::Linear { + let (a0, a1) = (Vec2::new(a, b), Vec2::new(c, d)); + let k = a0.dot(a1) / a1.hypot2(); + let n = a0 - a1 * k; + return Placed::Plain(Gradient { kind: GradientKind::Linear { start: (e, ff), end: (e + n.x, ff + n.y) }, stops }); + } + let (phi, sx, sy, theta) = svd(a, b, c, d); + if !(phi.is_finite() && sx.is_finite() && sy.is_finite() && theta.is_finite()) || sy.abs() <= 1e-9 * sx.abs() { + return Placed::Nothing; + } + let group = Affine::translate((e, ff)) * Affine::rotate(phi) * Affine::scale_non_uniform(sx, sy); + let transform = GroupTransform { translate: (e, ff), rotation: phi.to_degrees(), scale: (sx, sy) }; + let gradient = if shape == Shape::Sweep { + Gradient { kind: GradientKind::Sweep { center: (0.0, 0.0) }, stops: turn_stops(&stops, theta / std::f64::consts::TAU) } + } else { + Gradient { kind: GradientKind::Radial { center: (0.0, 0.0), radius: 1.0 }, stops } + }; + Placed::Grouped { transform, inverse: group.inverse(), gradient } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)] + + use kurbo::Point; + + use super::*; + + const RED: Color = [1.0, 0.0, 0.0, 1.0]; + const GREEN: Color = [0.0, 1.0, 0.0, 1.0]; + const BLUE: Color = [0.0, 0.0, 1.0, 1.0]; + const YELLOW: Color = [1.0, 1.0, 0.0, 1.0]; + + fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-3 + } + + fn stops() -> Vec<(f64, Color)> { + vec![(0.0, RED), (0.25, GREEN), (0.7, BLUE), (1.0, YELLOW)] + } + + /// Frames a layer's transform and a gradient's handles make: turned, skewed, mirrored. + fn frames() -> Vec { + vec![ + Affine::scale(10.0), + Affine::new([8.0, 0.0, 0.0, 3.0, 5.0, 5.0]), + Affine::new([4.0, 3.0, -2.0, 5.0, 1.0, -2.0]), + Affine::new([4.0, 3.0, 2.0, -5.0, 0.0, 0.0]), + Affine::rotate(1.1) * Affine::new([6.0, 1.0, 2.5, 4.0, -3.0, 7.0]), + ] + } + + /// Where on the gradient the written drawable samples viewport point `p`. + fn written_t(out: &Placed, p: Point) -> (f64, &Gradient) { + let (g, p) = match out { + Placed::Plain(g) => (g, p), + Placed::Grouped { transform, inverse, gradient } => { + // The group's transform as Android applies it puts the path back where it was. + let (sx, sy) = transform.scale; + let android = + Affine::translate(transform.translate) * Affine::rotate(transform.rotation.to_radians()) * Affine::scale_non_uniform(sx, sy); + let back = android * *inverse * p; + assert!(close(back.x, p.x) && close(back.y, p.y), "{back:?} vs {p:?}"); + (gradient, *inverse * p) + } + other => panic!("not a gradient: {other:?}"), + }; + let t = match g.kind { + GradientKind::Linear { start, end } => { + let (s, e) = (Point::from(start), Point::from(end)); + (p - s).dot(e - s) / (e - s).hypot2() + } + GradientKind::Radial { center, radius } => (p - Point::from(center)).hypot() / radius, + GradientKind::Sweep { center } => { + let v = p - Point::from(center); + v.y.atan2(v.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU + } + }; + (t, g) + } + + /// Where on the gradient the frame itself puts viewport point `p`. + fn frame_t(shape: Shape, frame: Affine, p: Point) -> f64 { + let u = frame.inverse() * p; + match shape { + Shape::Linear => u.x, + Shape::Radial => u.to_vec2().hypot(), + Shape::Sweep => u.y.atan2(u.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU, + } + } + + #[test] + fn every_gradient_samples_where_its_frame_samples_it() { + for frame in frames() { + for shape in [Shape::Linear, Shape::Radial, Shape::Sweep] { + let out = place(shape, stops(), frame); + assert_eq!(matches!(out, Placed::Plain(_)), shape == Shape::Linear, "{shape:?}"); + for (x, y) in [(0.3, 0.2), (-0.4, 0.6), (0.9, -0.1), (0.1, -0.8), (-0.5, -0.5)] { + let p = frame * Point::new(x, y); + let want = frame_t(shape, frame, p).clamp(0.0, 1.0); + let (got, grad) = written_t(&out, p); + let (a, b) = (color_at(&stops(), want), color_at(&grad.stops, got.clamp(0.0, 1.0))); + assert!((0..4).all(|i| (a[i] - b[i]).abs() < 1e-3), "{shape:?} {frame:?} at {x},{y}: {a:?} vs {b:?}"); + } + } + } + } + + #[test] + fn svd_puts_the_matrix_back_together() { + let ms = [[1.0, 0.0, 0.0, 1.0], [2.0, 0.5, -0.3, 0.7], [0.0, 3.0, -2.0, 0.0], [-1.0, 0.2, 0.4, 1.5], [0.3, -0.9, -1.1, -0.2]]; + for [a, b, c, d] in ms { + let (phi, sx, sy, theta) = svd(a, b, c, d); + assert!(sx >= sy.abs() - 1e-12, "{sx} {sy}"); + assert_eq!(sy < 0.0, a * d - b * c < 0.0, "a mirror shows in sy"); + let m = Affine::rotate(phi) * Affine::scale_non_uniform(sx, sy) * Affine::rotate(theta); + for (x, y) in m.as_coeffs().iter().zip([a, b, c, d]) { + assert!(close(*x, y), "{:?} vs {:?}", m.as_coeffs(), [a, b, c, d]); + } + } + } + + #[test] + fn turned_stops_keep_every_colour_where_it_was() { + let stops = stops(); + for shift in [0.1, 0.5, 0.95, -0.3, 1.25] { + let turned = turn_stops(&stops, shift); + assert!(turned.windows(2).all(|w| w[0].0 <= w[1].0), "sorted: {turned:?}"); + assert_eq!((turned[0].0, turned[turned.len() - 1].0), (0.0, 1.0)); + for i in 1..200 { + let t = f64::from(i) / 200.0; + let moved = (t + shift).rem_euclid(1.0); + if !moved.is_nan() && !(1e-6..=1.0 - 1e-6).contains(&moved) { + continue; + } + let (a, b) = (color_at(&stops, t), color_at(&turned, moved)); + assert!((0..3).all(|i| (a[i] - b[i]).abs() < 1e-4), "{shift}: {t}"); + } + } + assert_eq!(turn_stops(&stops, 0.0), stops); + assert_eq!(turn_stops(&stops, 2.0), stops); + assert_eq!(turn_stops(&stops, f64::NAN), stops); + } + + #[test] + fn turned_stops_on_the_seam_are_not_repeated() { + let out = turn_stops(&[(0.0, RED), (0.5, BLUE), (1.0, RED)], 0.5); + assert!(out.windows(2).all(|w| w[0] != w[1]), "{out:?}"); + } + + #[test] + fn a_collapsed_frame_paints_nothing_and_one_stop_is_solid() { + let two = vec![(0.0, RED), (1.0, BLUE)]; + for shape in [Shape::Linear, Shape::Radial, Shape::Sweep] { + // The width handle on the start point, both handles on one line, and numbers that aren't. + assert_eq!(place(shape, two.clone(), Affine::new([5.0, 0.0, 0.0, 0.0, 1.0, 1.0])), Placed::Nothing); + assert_eq!(place(shape, two.clone(), Affine::new([2.0, 4.0, 1.0, 2.0, 0.0, 0.0])), Placed::Nothing); + assert_eq!(place(shape, two.clone(), Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0])), Placed::Nothing); + assert_eq!(place(shape, two.clone(), Affine::new([1.0, 0.0, 0.0, 1.0, f64::INFINITY, 0.0])), Placed::Nothing); + assert_eq!(place(shape, vec![(0.3, GREEN)], Affine::IDENTITY), Placed::Solid(GREEN)); + assert_eq!(place(shape, vec![], Affine::IDENTITY), Placed::Nothing); + } + } + + #[test] + fn a_stroke_keeps_one_width_only_under_a_similarity() { + assert!(similarity(Affine::IDENTITY) && similarity(Affine::rotate(0.7) * Affine::scale(3.0))); + assert!(similarity(Affine::scale_non_uniform(2.0, -2.0)), "a flip"); + assert!(!similarity(Affine::scale_non_uniform(2.0, 1.0)) && !similarity(Affine::skew(0.3, 0.0))); + assert!(!similarity(Affine::scale(0.0)) && !similarity(Affine::scale(f64::NAN))); + } +} diff --git a/crates/vectordrawable/src/write.rs b/crates/vectordrawable/src/write.rs new file mode 100644 index 000000000..ec14fca97 --- /dev/null +++ b/crates/vectordrawable/src/write.rs @@ -0,0 +1,447 @@ +//! Writes a drawable as pretty-printed XML: elements two spaces deeper per level, one attribute +//! per line four spaces past its element (as Android Studio formats drawables). Numbers are +//! compact (at most three decimals, no `-0`) and always finite; text is escaped for attribute +//! values. + +use std::fmt::Write as _; + +use kurbo::{BezPath, PathEl}; + +use crate::model::{Cap, Color, Fill, GradientKind, Group, Item, ItemKind, Join, Path, Stroke}; + +/// Groups nested deeper than this are written without their children, so writing a drawable +/// never recurses without bound. Android Studio's own drawables nest a handful of levels. +pub const MAX_DEPTH: usize = 256; + +const ANDROID: &str = "http://schemas.android.com/apk/res/android"; +const AAPT: &str = "http://schemas.android.com/aapt"; + +/// A number as Android reads it: rounded to 3 decimals, no trailing zeros, `0` for anything +/// non-finite. +pub fn num(v: f64) -> String { + if !v.is_finite() { + return "0".into(); + } + // Android reads these as 32-bit floats, so clamp to that range; this also keeps the scaling + // below from overflowing to infinity. + let v = v.clamp(-f64::from(f32::MAX), f64::from(f32::MAX)); + let r = (v * 1000.0).round() / 1000.0; + let s = format!("{r}"); + if s == "-0" { "0".into() } else { s } +} + +/// `s` for an attribute value: markup escaped, whitespace that parsers would fold kept as +/// character references, and characters XML 1.0 forbids dropped. +pub fn esc(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + '\t' => out.push_str(" "), + '\n' => out.push_str(" "), + '\r' => out.push_str(" "), + c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {} + c => out.push(c), + } + } + out +} + +/// `#RRGGBB`, or `#AARRGGBB` when not opaque. +pub fn color(c: Color) -> String { + // A non-finite component is 0. + let [r, g, b, a] = c.map(|v| (if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } * 255.0).round() as u8); + if a == 255 { format!("#{r:02X}{g:02X}{b:02X}") } else { format!("#{a:02X}{r:02X}{g:02X}{b:02X}") } +} + +/// SVG path syntax, absolute commands. +pub fn path_data(p: &BezPath) -> String { + let mut s = String::new(); + let pt = |x: f64, y: f64| format!("{},{}", num(x), num(y)); + for el in p.elements() { + if !s.is_empty() { + s.push(' '); + } + match *el { + PathEl::MoveTo(a) => { + let _ = write!(s, "M{}", pt(a.x, a.y)); + } + PathEl::LineTo(a) => { + let _ = write!(s, "L{}", pt(a.x, a.y)); + } + PathEl::QuadTo(a, b) => { + let _ = write!(s, "Q{} {}", pt(a.x, a.y), pt(b.x, b.y)); + } + PathEl::CurveTo(a, b, c) => { + let _ = write!(s, "C{} {} {}", pt(a.x, a.y), pt(b.x, b.y), pt(c.x, c.y)); + } + PathEl::ClosePath => s.push('Z'), + } + } + s +} + +struct Out { + s: String, +} + +impl Out { + /// ``, else `>`. + fn open(&mut self, depth: usize, tag: &str, attrs: &[(&str, String)], close: bool) { + let pad = " ".repeat(depth); + let _ = write!(self.s, "{pad}<{tag}"); + for (i, (k, v)) in attrs.iter().enumerate() { + if i == 0 && attrs.len() == 1 { + let _ = write!(self.s, " {k}=\"{v}\""); + } else { + let _ = write!(self.s, "\n{pad} {k}=\"{v}\""); + } + } + self.s.push_str(if close { " />\n" } else { ">\n" }); + } + + fn close(&mut self, depth: usize, tag: &str) { + let _ = writeln!(self.s, "{}", " ".repeat(depth)); + } +} + +/// Whether a path that is written (see [`MAX_DEPTH`]) has a gradient fill. +fn uses_gradient(items: &[Item], depth: usize) -> bool { + items.iter().any(|it| match &it.kind { + ItemKind::Path(p) => matches!(p.fill, Some(Fill::Gradient(_))), + ItemKind::Group(g) => depth < MAX_DEPTH && uses_gradient(&g.children, depth + 1), + }) +} + +/// The whole file: a `width`×`height` dp drawable with a viewport of the same size. +pub fn document(width: f64, height: f64, items: &[Item]) -> String { + let mut o = Out { s: String::from("\n") }; + let mut attrs = vec![("xmlns:android", ANDROID.to_string())]; + if uses_gradient(items, 1) { + attrs.push(("xmlns:aapt", AAPT.to_string())); + } + attrs.extend([ + ("android:width", format!("{}dp", num(width))), + ("android:height", format!("{}dp", num(height))), + ("android:viewportWidth", num(width)), + ("android:viewportHeight", num(height)), + ]); + o.open(0, "vector", &attrs, false); + if items.is_empty() { + // Android refuses a without a ("no path defined"): one empty, unfilled path. + o.open(1, "path", &[("android:pathData", "M0,0".to_string())], true); + } + for it in items { + item(&mut o, 1, it); + } + o.close(0, "vector"); + o.s +} + +fn item(o: &mut Out, depth: usize, it: &Item) { + match &it.kind { + ItemKind::Path(p) => path(o, depth, p), + ItemKind::Group(g) => group(o, depth, g), + } +} + +fn group(o: &mut Out, depth: usize, g: &Group) { + let mut attrs = Vec::new(); + if let Some(n) = &g.name { + attrs.push(("android:name", esc(n))); + } + if let Some(x) = &g.transform { + let (sx, sy) = x.scale; + if num(x.rotation) != "0" { + attrs.push(("android:rotation", num(x.rotation))); + } + if num(sx) != "1" { + attrs.push(("android:scaleX", num(sx))); + } + if num(sy) != "1" { + attrs.push(("android:scaleY", num(sy))); + } + if num(x.translate.0) != "0" { + attrs.push(("android:translateX", num(x.translate.0))); + } + if num(x.translate.1) != "0" { + attrs.push(("android:translateY", num(x.translate.1))); + } + } + let children: &[Item] = if depth < MAX_DEPTH { &g.children } else { &[] }; + if children.is_empty() && g.clip.is_none() { + o.open(depth, "group", &attrs, true); + return; + } + o.open(depth, "group", &attrs, false); + if let Some(c) = &g.clip { + o.open(depth + 1, "clip-path", &[("android:pathData", path_data(c))], true); + } + for c in children { + item(o, depth + 1, c); + } + o.close(depth, "group"); +} + +fn stroke_attrs(attrs: &mut Vec<(&str, String)>, s: &Stroke) { + attrs.push(("android:strokeColor", color(s.color))); + attrs.push(("android:strokeWidth", num(s.width))); + match s.cap { + Cap::Butt => {} + Cap::Round => attrs.push(("android:strokeLineCap", "round".to_string())), + Cap::Square => attrs.push(("android:strokeLineCap", "square".to_string())), + } + match s.join { + Join::Round => attrs.push(("android:strokeLineJoin", "round".to_string())), + Join::Bevel => attrs.push(("android:strokeLineJoin", "bevel".to_string())), + Join::Miter if num(s.miter) != "4" => attrs.push(("android:strokeMiterLimit", num(s.miter))), + Join::Miter => {} + } +} + +fn path(o: &mut Out, depth: usize, p: &Path) { + let mut attrs = Vec::new(); + if let Some(n) = &p.name { + attrs.push(("android:name", esc(n))); + } + attrs.push(("android:pathData", path_data(&p.data))); + if let Some(Fill::Solid(c)) = &p.fill { + attrs.push(("android:fillColor", color(*c))); + } + if let Some(s) = &p.stroke { + stroke_attrs(&mut attrs, s); + } + if p.even_odd && p.fill.is_some() { + attrs.push(("android:fillType", "evenOdd".to_string())); + } + let Some(Fill::Gradient(g)) = &p.fill else { + o.open(depth, "path", &attrs, true); + return; + }; + o.open(depth, "path", &attrs, false); + o.open(depth + 1, "aapt:attr", &[("name", "android:fillColor".to_string())], false); + let mut ga = Vec::new(); + match g.kind { + GradientKind::Linear { start, end } => { + ga.push(("android:type", "linear".to_string())); + ga.push(("android:startX", num(start.0))); + ga.push(("android:startY", num(start.1))); + ga.push(("android:endX", num(end.0))); + ga.push(("android:endY", num(end.1))); + } + GradientKind::Radial { center, radius } => { + ga.push(("android:type", "radial".to_string())); + ga.push(("android:centerX", num(center.0))); + ga.push(("android:centerY", num(center.1))); + ga.push(("android:gradientRadius", num(radius))); + } + GradientKind::Sweep { center } => { + ga.push(("android:type", "sweep".to_string())); + ga.push(("android:centerX", num(center.0))); + ga.push(("android:centerY", num(center.1))); + } + } + o.open(depth + 2, "gradient", &ga, false); + for (off, c) in &g.stops { + o.open(depth + 3, "item", &[("android:offset", num(*off)), ("android:color", color(*c))], true); + } + o.close(depth + 2, "gradient"); + o.close(depth + 1, "aapt:attr"); + o.close(depth, "path"); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + + use kurbo::{Rect, Shape as _}; + + use super::*; + use crate::model::{Gradient, GroupTransform}; + + const RED: Color = [1.0, 0.0, 0.0, 1.0]; + + fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath { + Rect::new(x0, y0, x1, y1).to_path(0.1) + } + + fn filled(data: BezPath, fill: Fill) -> Item { + let bbox = data.bounding_box(); + Item { kind: ItemKind::Path(Path { name: None, data, fill: Some(fill), stroke: None, even_odd: false }), bbox } + } + + fn group(children: Vec) -> Item { + Item { kind: ItemKind::Group(Group { name: None, transform: None, clip: None, children }), bbox: Rect::ZERO } + } + + /// The written file, checked to be well-formed and free of numbers Android can't read. + fn written(width: f64, height: f64, items: &[Item]) -> String { + let xml = document(width, height, items); + if let Err(e) = craft_xml::parse(xml.as_bytes()) { + panic!("not well-formed: {e}\n{xml}"); + } + for bad in ["NaN", "inf", "=\"-0\"", "e+", "e-"] { + assert!(!xml.contains(bad), "{bad} in\n{xml}"); + } + xml + } + + #[test] + fn numbers_are_short_finite_and_never_negative_zero() { + assert_eq!(num(1.0), "1"); + assert_eq!(num(0.12345), "0.123"); + assert_eq!(num(-0.0004), "0"); + assert_eq!(num(-2.5), "-2.5"); + assert_eq!(num(1e-9), "0"); + assert_eq!(num(12345678.9), "12345678.9"); + assert_eq!(num(f64::NAN), "0"); + assert_eq!(num(f64::INFINITY), "0"); + assert!(!num(f64::MAX).contains("inf") && !num(-f64::MAX).contains("inf"), "clamped to the f32 range"); + } + + #[test] + fn colours_are_rrggbb_or_aarrggbb() { + assert_eq!(color([26.0 / 255.0, 43.0 / 255.0, 60.0 / 255.0, 1.0]), "#1A2B3C"); + assert_eq!(color([26.0 / 255.0, 43.0 / 255.0, 60.0 / 255.0, 128.0 / 255.0]), "#801A2B3C"); + assert_eq!(color([2.0, -1.0, f32::NAN, 1.0]), "#FF0000"); + assert_eq!(color([0.0, 0.0, 0.0, f32::NAN]), "#00000000"); + } + + #[test] + fn text_is_escaped_for_an_attribute() { + assert_eq!(esc("a&b\"d'"), "a&b<c>"d'"); + assert_eq!(esc("tab\tline\nret\r"), "tab line ret "); + assert_eq!(esc("bell\u{7}\u{FFFE}ok"), "bellok"); + } + + #[test] + fn the_file_is_pretty_printed() { + let clip = Group { + name: None, + transform: None, + clip: Some(rect(0.0, 0.0, 24.0, 24.0)), + children: vec![filled(rect(4.0, 4.0, 20.0, 12.0), Fill::Solid(RED))], + }; + let xml = written(24.0, 24.0, &[Item { kind: ItemKind::Group(clip), bbox: Rect::new(0.0, 0.0, 24.0, 24.0) }]); + let want = " + + + + + + +"; + assert_eq!(xml, want); + } + + #[test] + fn an_empty_drawable_is_still_one_android_loads() { + // Android throws "no path defined" for a without a . + let xml = written(10.0, 10.0, &[]); + assert!(xml.ends_with("android:viewportHeight=\"10\">\n \n\n"), "{xml}"); + } + + #[test] + fn names_are_escaped_and_a_name_cannot_add_markup() { + let mut it = filled(rect(0.0, 0.0, 1.0, 1.0), Fill::Solid(RED)); + if let ItemKind::Path(p) = &mut it.kind { + p.name = Some("Icon & \"co\" <1>\" android:pathData=\"M9,9".into()); + } + let xml = written(1.0, 1.0, &[it]); + let root = craft_xml::parse(xml.as_bytes()).unwrap(); + let path = root.elements().next().unwrap(); + assert_eq!(path.attr("android:name"), Some("Icon & \"co\" <1>\" android:pathData=\"M9,9")); + assert_eq!(path.attr("android:pathData"), Some("M0,0 L1,0 L1,1 L0,1 Z")); + } + + #[test] + fn a_stroke_writes_only_what_differs_from_androids_defaults() { + let stroke = |cap, join, miter| { + let s = Stroke { color: [0.0, 0.0, 1.0, 0.5], width: 2.5, cap, join, miter }; + let p = Path { name: None, data: rect(0.0, 0.0, 1.0, 1.0), fill: None, stroke: Some(s), even_odd: true }; + written(1.0, 1.0, &[Item { kind: ItemKind::Path(p), bbox: Rect::ZERO }]) + }; + let plain = stroke(Cap::Butt, Join::Miter, 4.0); + assert!(plain.contains("android:strokeColor=\"#800000FF\"") && plain.contains("android:strokeWidth=\"2.5\""), "{plain}"); + for absent in ["strokeLineCap", "strokeLineJoin", "strokeMiterLimit", "fillColor", "fillType"] { + assert!(!plain.contains(absent), "{absent} in {plain}"); + } + let round = stroke(Cap::Round, Join::Round, 10.0); + assert!(round.contains("android:strokeLineCap=\"round\"") && round.contains("android:strokeLineJoin=\"round\""), "{round}"); + assert!(!round.contains("strokeMiterLimit"), "a miter limit means nothing on a round join: {round}"); + let square = stroke(Cap::Square, Join::Bevel, 4.0); + assert!(square.contains("android:strokeLineCap=\"square\"") && square.contains("android:strokeLineJoin=\"bevel\""), "{square}"); + assert!(stroke(Cap::Butt, Join::Miter, 2.0).contains("android:strokeMiterLimit=\"2\"")); + } + + #[test] + fn an_even_odd_fill_is_marked_and_a_group_writes_its_transform() { + let mut it = filled(rect(0.0, 0.0, 1.0, 1.0), Fill::Solid(RED)); + if let ItemKind::Path(p) = &mut it.kind { + p.even_odd = true; + } + let xf = GroupTransform { translate: (3.0, -4.0), rotation: 90.0, scale: (2.0, 1.0) }; + let g = Group { name: Some("g".into()), transform: Some(xf), clip: None, children: vec![it] }; + let xml = written(8.0, 8.0, &[Item { kind: ItemKind::Group(g), bbox: Rect::ZERO }]); + assert!(xml.contains("android:fillType=\"evenOdd\""), "{xml}"); + for want in ["android:name=\"g\"", "android:rotation=\"90\"", "android:scaleX=\"2\"", "android:translateX=\"3\"", "android:translateY=\"-4\""] + { + assert!(xml.contains(want), "{want} not in {xml}"); + } + assert!(!xml.contains("scaleY"), "1 is the default: {xml}"); + // An identity transform and no children: one empty element. + let empty = Group { + name: None, + transform: Some(GroupTransform { translate: (0.0, 0.0), rotation: 0.0, scale: (1.0, 1.0) }), + clip: None, + children: vec![], + }; + assert!(written(8.0, 8.0, &[Item { kind: ItemKind::Group(empty), bbox: Rect::ZERO }]).contains(" \n")); + } + + #[test] + fn gradients_are_inline_resources_and_declare_the_aapt_namespace() { + let stops = vec![(0.0, RED), (1.0, [0.0, 0.0, 1.0, 0.5])]; + let kinds = [ + (GradientKind::Linear { start: (0.0, 0.0), end: (10.0, 0.0) }, "android:type=\"linear\"", "android:endX=\"10\""), + (GradientKind::Radial { center: (5.0, 5.0), radius: 5.0 }, "android:type=\"radial\"", "android:gradientRadius=\"5\""), + (GradientKind::Sweep { center: (5.0, 6.0) }, "android:type=\"sweep\"", "android:centerY=\"6\""), + ]; + for (kind, type_attr, own) in kinds { + let xml = + written(10.0, 10.0, &[group(vec![filled(rect(0.0, 0.0, 10.0, 10.0), Fill::Gradient(Gradient { kind, stops: stops.clone() }))])]); + assert!(xml.contains("xmlns:aapt=\"http://schemas.android.com/aapt\""), "{xml}"); + assert!(xml.contains("") && xml.contains(type_attr) && xml.contains(own), "{xml}"); + assert!(xml.contains("android:offset=\"0\"\n") && xml.contains("android:color=\"#800000FF\""), "{xml}"); + assert!(!xml.contains("android:fillColor=\""), "the fill is the gradient alone: {xml}"); + } + assert!(!written(1.0, 1.0, &[filled(rect(0.0, 0.0, 1.0, 1.0), Fill::Solid(RED))]).contains("aapt")); + } + + #[test] + fn groups_past_the_depth_cap_are_written_empty() { + let mut deep = filled(rect(0.0, 0.0, 1.0, 1.0), Fill::Gradient(Gradient { kind: GradientKind::Sweep { center: (0.0, 0.0) }, stops: vec![] })); + for _ in 0..MAX_DEPTH { + deep = group(vec![deep]); + } + // MAX_DEPTH groups: the innermost is at the cap and is written without the path. + let xml = document(1.0, 1.0, std::slice::from_ref(&deep)); + assert_eq!(xml.matches("