mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 11:54:50 +00:00
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:
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
/// `<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&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 = "<?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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user