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.
This commit is contained in:
Dylan M. Taylor
2026-11-11 16:23:23 -05:00
committed by GitHub
parent feb941cc19
commit 81307f8274
8 changed files with 927 additions and 0 deletions
Generated
+8
View File
@@ -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"
+1
View File
@@ -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" }
+4
View File
@@ -152,6 +152,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-vectorcraft-format`](crates/vectorcraft-format/README.md): the envelope of VectorCraft's
`.vectorcraft` file: its members, versions, embedded images and the data after the JSON, with the
document as a type parameter. The document and its migrations stay in VectorCraft.
- [`craft-vectordrawable`](crates/vectordrawable/src/lib.rs): Android Vector Drawable writer: a model of
paths, groups, clip outlines, strokes and linear, radial and sweep gradients; the XML as Android
Studio formats it; and the mapping of a gradient on any affine frame to what a drawable can say.
What a drawable cannot draw is the caller's to bake in or leave out. No reader.
- [`craft-web-api`](crates/web-api/README.md): the page command API every Craft web build offers its page — one
`window.<app>` shape (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, `screenshot`) over the app's
JSON control channel.
+17
View File
@@ -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 }
+23
View File
@@ -0,0 +1,23 @@
//! Android Vector Drawable: a `<vector>` XML drawable written from a small model.
//!
//! Written from Android's public `VectorDrawable` documentation (elements `<vector>`, `<group>`,
//! `<path>`, `<clip-path>`, 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 `<group>` 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
//! `<group>` can only scale, rotate and translate, and a `<path>` 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;
+147
View File
@@ -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<String>,
pub data: BezPath,
pub fill: Option<Fill>,
pub stroke: Option<Stroke>,
/// `fillType="evenOdd"` (API 24); non-zero otherwise.
pub even_odd: bool,
}
/// A `<group>`'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<String>,
pub transform: Option<GroupTransform>,
/// A non-zero clip outline in the group's (untransformed) coordinates.
pub clip: Option<BezPath>,
pub children: Vec<Item>,
}
#[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<Rect>) {
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
})
})
}
}
+280
View File
@@ -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
//! `<group>` 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 `<group>` 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<Affine> {
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)));
}
}
+447
View File
@@ -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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
'\t' => out.push_str("&#9;"),
'\n' => out.push_str("&#10;"),
'\r' => out.push_str("&#13;"),
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 {
/// `<tag` and its attributes, one per line; `close` ends it with `/>`, 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, "{}</{tag}>", " ".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("<?xml version=\"1.0\" encoding=\"utf-8\"?>\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 <vector> without a <path> ("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 {
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<c>\"d'"), "a&amp;b&lt;c&gt;&quot;d&apos;");
assert_eq!(esc("tab\tline\nret\r"), "tab&#9;line&#10;ret&#13;");
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 = "<?xml version=\"1.0\" encoding=\"utf-8\"?>
<vector
xmlns:android=\"http://schemas.android.com/apk/res/android\"
android:width=\"24dp\"
android:height=\"24dp\"
android:viewportWidth=\"24\"
android:viewportHeight=\"24\">
<group>
<clip-path android:pathData=\"M0,0 L24,0 L24,24 L0,24 Z\" />
<path
android:pathData=\"M4,4 L20,4 L20,12 L4,12 Z\"
android:fillColor=\"#FF0000\" />
</group>
</vector>
";
assert_eq!(xml, want);
}
#[test]
fn an_empty_drawable_is_still_one_android_loads() {
// Android throws "no path defined" for a <vector> without a <path>.
let xml = written(10.0, 10.0, &[]);
assert!(xml.ends_with("android:viewportHeight=\"10\">\n <path android:pathData=\"M0,0\" />\n</vector>\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(" <group />\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("<aapt:attr name=\"android:fillColor\">") && 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("<group").count(), MAX_DEPTH);
assert!(!xml.contains("<path") && !xml.contains("aapt"), "nothing past the cap is written or declared");
// One group fewer and the path is written.
let ItemKind::Group(outer) = deep.kind else { panic!("a group") };
let xml = document(1.0, 1.0, &outer.children);
assert!(xml.contains("<path") && xml.contains("xmlns:aapt"));
}
}