M6: figcraft-vectordrawable: an Android Vector Drawable writer

A new L4 crate that writes layers as an Android Vector Drawable (XML):
shapes, booleans and text as baked path data, solid and gradient fills
(linear, radial and sweep, exact for any handle frame via a group and a
2x2 SVD), native strokes where Android draws them the same and filled
stroke outlines otherwise, clip frames and masks as <clip-path> groups.

What a drawable can't hold is governed by Compat (bestEffort / exact /
ignore): layer opacity over overlapping shapes, even-odd fills and clips
(rewritten to non-zero via pathops), diamond gradients, alpha masks;
effects, image and pattern fills and blend modes are left out with a
warning. Output is pretty-printed, numbers are finite and short, names
(android:name) are optional, escaped and unique.
This commit is contained in:
Filip Strajnar
2026-10-18 20:24:19 +02:00
parent d8ff1441b1
commit 8b5441eb84
18 changed files with 2318 additions and 0 deletions
Generated
+14
View File
@@ -1722,6 +1722,20 @@ dependencies = [
"web-time",
]
[[package]]
name = "figcraft-vectordrawable"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"roxmltree 0.21.1",
"thiserror 2.0.21",
]
[[package]]
name = "figcraft-web"
version = "0.7.0"
+1
View File
@@ -23,6 +23,7 @@ figcraft-pathops = { path = "crates/pathops" }
figcraft-render = { path = "crates/render" }
figcraft-svg = { path = "crates/svg" }
figcraft-pdf = { path = "crates/pdf" }
figcraft-vectordrawable = { path = "crates/vectordrawable" }
figcraft-format = { path = "crates/format" }
figcraft-tools = { path = "crates/tools" }
figcraft-collab = { path = "crates/collab" }
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "figcraft-vectordrawable"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft Android Vector Drawable export (shapes, strokes, gradients, clips and vector masks as a <vector> XML drawable)"
[lints]
workspace = true
[dependencies]
figcraft-geom = { workspace = true }
figcraft-color = { workspace = true }
figcraft-doc = { workspace = true }
figcraft-text = { workspace = true }
figcraft-render = { workspace = true }
figcraft-pathops = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
roxmltree = "0.21"
+111
View File
@@ -0,0 +1,111 @@
//! Android Vector Drawable export: the exported layers as one `<vector>` XML drawable.
//!
//! Written from Android's public `VectorDrawable` documentation (elements `<vector>`, `<group>`,
//! `<path>`, `<clip-path>`, gradients as inline `GradientColor` resources through `aapt:attr`).
//! The drawable is as large as the exported area in dp (1 px = 1 dp), with a viewport of the same
//! size, so path coordinates are page units relative to the area's top-left corner.
//!
//! What maps exactly:
//! - shapes, booleans, vector networks, frames and text: their outlines
//! (`figcraft_render::node_outline`, glyph outlines per styled run), with every transform baked
//! into the path data (a `<group>` can only scale, rotate and translate, a `<path>` not at all);
//! - solid fills and paint, layer and stop opacity, folded into `#AARRGGBB` colours;
//! - centred, uniform, solid strokes as stroke attributes (width, cap, join, miter limit); every
//! other stroke (inside, outside, dashed, variable width, arrows, per-side weights, gradient
//! paints, under a skew or non-uniform scale) as its filled outline
//! (`figcraft_render::draw::stroke_area`);
//! - linear, radial and angular (sweep) gradients, for any handle frame: a linear gradient's end
//! point is projected so it bands along the width handle; radial and sweep gradients go in a
//! `<group>` that scales and rotates the unit circle onto the handle ellipse (a 2×2 singular
//! value decomposition; the remaining rotation turns a sweep's stops), with the path mapped back;
//! - clipping frames and vector masks, as a `<group>` with a `<clip-path>`.
//!
//! What can't map exactly depends on [`Compat`]: layer opacity over overlapping shapes, even-odd
//! fills and clip outlines, diamond gradients, alpha and luminance masks. Images, patterns,
//! effects and blend modes have no Vector Drawable equivalent and are left out (blend modes draw
//! as normal). Every change is listed in [`VdExport::warnings`] unless the mode is
//! [`Compat::Ignore`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod model;
mod paint;
mod stroke;
mod text;
mod walk;
mod warnings;
mod xml;
use figcraft_doc::{Document, NodeId};
use figcraft_geom::{Affine, Rect};
use figcraft_render::{ExportDepthError, check_export_depth};
/// How the export handles what a Vector Drawable can't draw exactly.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Compat {
/// The closest drawing (layer opacity on each shape, even-odd fills and clip outlines rewritten as non-zero, diamond
/// gradients as radial, alpha masks as vector masks), with a warning for each change.
#[default]
BestEffort,
/// Never approximate: a layer or paint that can't be drawn exactly is left out, with a warning.
Exact,
/// Write what maps and leave out the rest without warnings (layer opacity over overlapping
/// shapes is dropped, even-odd fills stay `fillType="evenOdd"`, even-odd clip outlines clip
/// as non-zero, diamond gradients are left out, alpha masks clip to their outline).
Ignore,
}
#[derive(Clone, Debug, Default)]
pub struct VdOptions {
/// The page area the drawable covers (default: the exported layers' export bounds).
pub bounds: Option<Rect>,
/// Layer names as `android:name` attributes (unique in the file: repeats get `_2`, `_3`…).
pub include_names: bool,
pub compat: Compat,
}
/// A written drawable and what was changed or left out on the way.
#[derive(Clone, Debug, PartialEq)]
pub struct VdExport {
pub xml: String,
pub warnings: Vec<String>,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum VdError {
#[error("nothing to export")]
Empty,
#[error(transparent)]
Depth(#[from] ExportDepthError),
}
/// The layers `ids` (bottom to top) as an Android Vector Drawable. Colours are written as they
/// are: convert a Display P3 document to sRGB first (`Document::in_srgb`).
pub fn try_export(doc: &Document, ids: &[NodeId], opts: &VdOptions) -> Result<VdExport, VdError> {
check_export_depth(doc, ids)?;
let area = match opts.bounds {
Some(b) if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) => b,
_ => ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b)).ok_or(VdError::Empty)?,
};
let (w, h) = (area.width(), area.height());
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return Err(VdError::Empty);
}
let origin = Affine::translate((-area.x0, -area.y0));
let mut walker = walk::Walker::new(doc, opts);
let mut items = Vec::new();
for id in ids {
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
items.extend(walker.node(*id, origin * parent_xf, 0));
}
// Nothing drawn still writes a drawable Android loads (one empty path), so a batch export
// keeps going, but say so.
if items.is_empty() {
let first = ids.first().and_then(|i| doc.node(*i)).map(|n| n.name.clone()).unwrap_or_default();
walker.nothing_drawn(&first);
}
let xml = xml::document(w, h, &items);
Ok(VdExport { xml, warnings: walker.finish() })
}
#[cfg(test)]
mod tests;
+122
View File
@@ -0,0 +1,122 @@
//! What the walk produces before it's written: paths and groups in viewport coordinates.
use figcraft_color::Color;
use figcraft_geom::{BezPath, Rect};
/// A gradient in the coordinates of the path it fills.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum GradKind {
Linear { start: (f64, f64), end: (f64, f64) },
Radial { center: (f64, f64), radius: f64 },
Sweep { center: (f64, f64) },
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Grad {
pub kind: GradKind,
/// Offsets in 0..=1, ascending; colours with their opacity folded in.
pub stops: Vec<(f64, Color)>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Fill {
Solid(Color),
Gradient(Grad),
}
/// A stroke Android draws itself (centred, uniform, solid).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeStroke {
pub color: Color,
pub width: f64,
pub cap: &'static str,
pub join: &'static str,
pub miter: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct PathItem {
pub name: Option<String>,
pub data: BezPath,
pub fill: Option<Fill>,
pub stroke: Option<NativeStroke>,
pub even_odd: bool,
}
/// A `<group>`'s own transform: scale, then rotate (degrees), then translate.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct GroupXf {
pub translate: (f64, f64),
pub rotation: f64,
pub scale: (f64, f64),
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct GroupItem {
pub name: Option<String>,
pub xf: Option<GroupXf>,
/// A non-zero clip outline in the group's (untransformed) coordinates.
pub clip: Option<BezPath>,
pub children: Vec<Item>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ItemKind {
Path(PathItem),
Group(GroupItem),
}
/// One written element with the viewport area it paints (for the opacity overlap test).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Item {
pub kind: ItemKind,
pub bbox: Rect,
}
impl Item {
/// The areas of what paints, each a leaf (a path, or a transformed gradient group).
pub fn leaf_boxes(items: &[Item], out: &mut Vec<Rect>) {
for it in items {
match &it.kind {
ItemKind::Group(g) if g.xf.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).
pub fn scale_alpha(items: &mut [Item], k: f32) {
let fade = |c: &mut Color| c.a = (c.a * 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 `MAX` 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
})
})
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Gradients in Vector Drawable terms, and the even-odd → non-zero rewrite.
//!
//! A gradient's handles map the unit box onto the layer: (0,0) → from, (1,0) → to, (0,1) →
//! width, a frame `F` (with the layer's transform, into the viewport). 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 figcraft_color::{Color, Gradient};
use figcraft_geom::{Affine, BezPath, Vec2};
use crate::model::{Grad, GradKind, GroupXf};
/// A gradient fill, ready to write.
#[derive(Debug, PartialEq)]
pub(crate) enum GradOut {
/// In viewport coordinates, on the path as it is.
Plain(Grad),
/// In the coordinates of a `<group>` with this transform; `inverse` maps the path into them.
Grouped { xf: GroupXf, inverse: Affine, grad: Grad },
/// One stop: a solid colour.
Solid(Color),
/// A frame that collapses to a line or a point: the renderer paints nothing, nor do we.
Nothing,
}
/// The gradient handle frame in a `w`×`h` layer's local space.
pub(crate) fn frame(g: &Gradient, w: f64, h: f64) -> Affine {
let f = (g.from.0 * w, g.from.1 * h);
let t = (g.to.0 * w, g.to.1 * h);
let k = (g.width.0 * w, g.width.1 * h);
Affine::new([t.0 - f.0, t.1 - f.1, k.0 - f.0, k.1 - f.1, f.0, f.1])
}
/// `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(crate) 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(crate) fn color_at(stops: &[(f64, Color)], t: f64) -> Color {
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Color::new(0.0, 0.0, 0.0, 0.0) };
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 };
let mix = |x: f32, y: f32| x + (y - x) * k;
return Color::new(mix(c0.r, c1.r), mix(c0.g, c1.g), mix(c0.b, c1.b), mix(c0.a, c1.a));
}
}
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(crate) fn turn_stops(stops: &[(f64, Color)], shift: f64) -> Vec<(f64, Color)> {
let d = shift.rem_euclid(1.0);
if !d.is_finite() || d < 1e-9 || d > 1.0 - 1e-9 || 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
}
/// Gradient `g` on a `w`×`h` layer drawn through `to_view` (local → viewport), its stops faded by
/// `opacity`. `radial` draws an angular or diamond gradient as radial (callers decide when).
pub(crate) fn gradient(g: &Gradient, opacity: f32, to_view: Affine, w: f64, h: f64, radial: bool) -> GradOut {
use figcraft_color::GradientKind;
let stops: Vec<(f64, Color)> =
g.sorted_stops().iter().map(|s| (f64::from(s.position), s.color.with_alpha(s.color.a * opacity.clamp(0.0, 1.0)))).collect();
match stops.as_slice() {
[] => return GradOut::Nothing,
[(_, c)] => return GradOut::Solid(*c),
_ => {}
}
let f = to_view * frame(g, w, h);
let [a, b, c, d, e, ff] = f.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 GradOut::Nothing;
}
let kind = if radial { GradientKind::Radial } else { g.kind };
match kind {
GradientKind::Linear => {
let (a0, a1) = (Vec2::new(a, b), Vec2::new(c, d));
let k = a0.dot(a1) / a1.hypot2();
let n = a0 - a1 * k;
GradOut::Plain(Grad { kind: GradKind::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 GradOut::Nothing;
}
let group = Affine::translate((e, ff)) * Affine::rotate(phi) * Affine::scale_non_uniform(sx, sy);
let xf = GroupXf { translate: (e, ff), rotation: phi.to_degrees(), scale: (sx, sy) };
let grad = if kind == GradientKind::Angular {
Grad { kind: GradKind::Sweep { center: (0.0, 0.0) }, stops: turn_stops(&stops, theta / std::f64::consts::TAU) }
} else {
Grad { kind: GradKind::Radial { center: (0.0, 0.0), radius: 1.0 }, stops }
};
GradOut::Grouped { xf, inverse: group.inverse(), grad }
}
}
}
/// `path` (filled even-odd) as a path that fills the same area with the non-zero rule: the faces
/// of its arrangement, each an outer contour with its holes wound the other way. `None` when the
/// path ops can't take it.
pub(crate) fn nonzero(path: &BezPath) -> Option<BezPath> {
let faces = figcraft_pathops::faces(&[(path.clone(), true)]).ok()?;
let mut out = BezPath::new();
for f in faces {
out.extend(f.path.elements().iter().copied());
}
Some(out)
}
+62
View File
@@ -0,0 +1,62 @@
//! Strokes Android can draw itself, and the rule for the filled outline of the rest.
use figcraft_doc::{Node, NodeKind, StrokeAlign, StrokeCap, StrokeJoin};
use figcraft_geom::{Affine, BezPath};
/// A stroke's Vector Drawable attributes: its path (local), width (viewport), cap, join, miter.
pub(crate) struct Native {
pub path: BezPath,
pub width: f64,
pub cap: &'static str,
pub join: &'static str,
pub miter: f64,
}
/// Whether `xf` keeps angles and scales evenly (a rotation, flip and uniform scale), so a stroke
/// width stays one width.
pub(crate) 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
}
/// `n`'s stroke as stroke attributes when Android draws it the same: centred, uniform, without
/// dashes, arrows or per-point styles, on a shape (not text or a boolean) drawn through a
/// similarity `xf`. `None`: write its filled outline.
pub(crate) fn native(n: &Node, xf: Affine) -> Option<Native> {
let k = &n.stroke;
if k.align != StrokeAlign::Center
|| k.sides.is_some()
|| k.profile().is_some()
|| k.brush.is_some()
|| !k.dashes.is_empty()
|| k.cap_start != k.cap_end
|| k.cap_start.is_arrow()
|| !(k.weight.is_finite() && k.weight > 0.0)
|| matches!(n.kind, NodeKind::Text(_) | NodeKind::BooleanOp { .. } | NodeKind::Group | NodeKind::Page { .. })
|| matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles(k.join))
|| !similarity(xf)
{
return None;
}
let path = figcraft_doc::geometry::stroke_geometry(n)?;
let width = k.weight * xf.determinant().abs().sqrt();
let cap = match k.cap_start {
StrokeCap::Round => "round",
StrokeCap::Square => "square",
_ => "butt",
};
let join = match k.join {
StrokeJoin::Miter => "miter",
StrokeJoin::Round => "round",
StrokeJoin::Bevel => "bevel",
};
let miter = if k.miter_limit.is_finite() { k.miter_limit.max(1.0) } else { 4.0 };
width.is_finite().then_some(Native { path, width, cap, join, miter })
}
/// Whether `n`'s filled stroke outline needs the even-odd rule (rings of inside and outside
/// strokes, per-side strokes), as the renderer fills it.
pub(crate) fn outline_even_odd(n: &Node, closed: bool) -> bool {
n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != StrokeAlign::Center)
}
+261
View File
@@ -0,0 +1,261 @@
use super::*;
use crate::paint::nonzero;
use figcraft_color::BlendMode;
use figcraft_doc::{Effect, MaskType};
use figcraft_geom::{BezPath, Point, Shape};
fn fills(xml: &str) -> Vec<String> {
tagged(&elements(xml), "path").iter().filter_map(|e| e.get("fillColor").map(str::to_string)).collect()
}
/// A 50% frame over two rectangles, apart or overlapping.
fn faded(overlap: bool) -> (Document, NodeId) {
let (mut d, p) = doc();
let f = d.create(p, NodeKind::Frame, "Card", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(f).unwrap().fills.clear();
d.get_mut(f).unwrap().opacity = 0.5;
rect(&mut d, f, "A", 0.0, 0.0, 40.0, 40.0, "FF0000");
let x = if overlap { 20.0 } else { 60.0 };
rect(&mut d, f, "B", x, 20.0, 40.0, 40.0, "0000FF");
(d, f)
}
#[test]
fn opacity_over_shapes_apart_is_exact_in_every_mode() {
let (d, f) = faded(false);
for compat in [Compat::BestEffort, Compat::Exact, Compat::Ignore] {
let out = export(&d, &[f], compat);
assert_eq!(fills(&out.xml), ["#80FF0000", "#800000FF"], "{compat:?}");
assert!(out.warnings.is_empty(), "{compat:?}: {:?}", out.warnings);
}
}
#[test]
fn opacity_over_overlapping_shapes_per_mode() {
let (d, f) = faded(true);
let best = export(&d, &[f], Compat::BestEffort);
assert_eq!(fills(&best.xml), ["#80FF0000", "#800000FF"]);
assert_eq!(best.warnings, ["layer opacity was applied to each shape: where shapes overlap they look denser (Card)"]);
let exact = export(&d, &[f], Compat::Exact);
assert!(fills(&exact.xml).is_empty(), "{}", exact.xml);
assert_eq!(
exact.warnings,
[
"layers with opacity over overlapping shapes were left out (exact mode) (Card)",
"nothing in it could be drawn: the drawable is empty (Card)"
]
);
let ignore = export(&d, &[f], Compat::Ignore);
assert_eq!(fills(&ignore.xml), ["#FF0000", "#0000FF"]);
assert!(ignore.warnings.is_empty());
}
#[test]
fn a_faded_leaf_with_a_fill_and_a_stroke_overlaps_itself() {
let (mut d, p) = doc();
let r = rect(&mut d, p, "R", 0.0, 0.0, 40.0, 40.0, "FF0000");
let n = d.get_mut(r).unwrap();
n.strokes = vec![Paint::solid(hex("000000"))];
n.stroke.weight = 2.0;
n.stroke.align = figcraft_doc::StrokeAlign::Center;
n.opacity = 0.5;
let w = export(&d, &[r], Compat::BestEffort).warnings;
assert_eq!(w, ["layer opacity was applied to each shape: where shapes overlap they look denser (R)"]);
let (mut d, p) = doc();
let r = rect(&mut d, p, "R", 0.0, 0.0, 40.0, 40.0, "FF0000");
d.get_mut(r).unwrap().opacity = 0.5;
let out = export(&d, &[r], Compat::Exact);
assert_eq!((fills(&out.xml), out.warnings), (vec!["#80FF0000".to_string()], vec![]));
}
#[test]
fn even_odd_fills_per_mode() {
let (mut d, p) = doc();
let a = rect(&mut d, p, "A", 0.0, 0.0, 60.0, 60.0, "000000");
let b = rect(&mut d, p, "B", 20.0, 20.0, 20.0, 20.0, "000000");
let bo = d.create(p, NodeKind::BooleanOp { op: figcraft_doc::BoolOp::Exclude }, "Ring", 0.0, 0.0, 1.0, 1.0).unwrap();
d.reparent(a, bo, None).unwrap();
d.reparent(b, bo, None).unwrap();
d.fit_to_children(bo).unwrap();
d.get_mut(bo).unwrap().fills = vec![Paint::solid(hex("336699"))];
let best = export(&d, &[bo], Compat::BestEffort);
let path = paths_of(&best.xml)[0].clone();
assert!(path.get("fillType").is_none(), "{}", best.xml);
let ring = super::strokes::parse_path(path.get("pathData").unwrap());
assert_ne!(ring.winding(Point::new(5.0, 5.0)), 0);
assert_eq!(ring.winding(Point::new(30.0, 30.0)), 0, "the hole stays a hole under non-zero");
// Exact keeps the rule (AndroidX draws it), as does ignore.
for compat in [Compat::Exact, Compat::Ignore] {
let out = export(&d, &[bo], compat);
assert_eq!(paths_of(&out.xml)[0].get("fillType"), Some("evenOdd"), "{compat:?}");
assert!(out.warnings.is_empty());
}
assert_eq!(best.warnings, ["even-odd fills were rewritten as non-zero (Ring)"]);
}
/// Even-odd inside-ness of `p` against non-zero inside-ness of `nonzero(p)`, on a grid.
fn same_area(p: &BezPath) {
let q = nonzero(p).unwrap();
let b = p.bounding_box();
let mut inside = 0;
for i in 0..60 {
for j in 0..60 {
let pt = Point::new(b.x0 + b.width() * (i as f64 + 0.37) / 60.0, b.y0 + b.height() * (j as f64 + 0.41) / 60.0);
let even = p.winding(pt) % 2 != 0;
assert_eq!(even, q.winding(pt) != 0, "at {pt:?}");
inside += usize::from(even);
}
}
assert!(inside > 100, "{inside}");
}
#[test]
fn the_non_zero_rewrite_fills_the_same_area() {
// A pentagram: the middle pentagon is winding 2, outside under even-odd.
let mut star = BezPath::new();
for k in 0..5 {
let a = std::f64::consts::TAU * (k * 2 % 5) as f64 / 5.0 - std::f64::consts::FRAC_PI_2;
let pt = Point::new(50.0 + 40.0 * a.cos(), 50.0 + 40.0 * a.sin());
if k == 0 { star.move_to(pt) } else { star.line_to(pt) }
}
star.close_path();
same_area(&star);
// Nested squares wound the same way (three levels) and an overlapping circle.
let mut nest = BezPath::new();
for (o, s) in [(0.0, 90.0), (15.0, 60.0), (30.0, 30.0)] {
nest.extend(figcraft_geom::Rect::new(o, o, o + s, o + s).to_path(0.1));
}
nest.extend(figcraft_geom::kurbo::Circle::new((80.0, 80.0), 25.0).to_path(0.1));
same_area(&nest);
}
#[test]
fn alpha_masks_per_mode() {
let (mut d, p) = doc();
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
let m = d.create(g, ellipse(), "Fade", 10.0, 10.0, 80.0, 80.0).unwrap();
d.get_mut(m).unwrap().is_mask = true;
d.get_mut(m).unwrap().mask_type = MaskType::Alpha;
d.get_mut(m).unwrap().fills = vec![Paint::solid(hex("80808080"))];
rect(&mut d, g, "Content", 0.0, 0.0, 100.0, 100.0, "2255FF");
let best = export(&d, &[g], Compat::BestEffort);
assert!(best.xml.contains("<clip-path") && best.xml.contains("#2255FF"), "{}", best.xml);
assert_eq!(best.warnings, ["alpha and luminance masks clip to the mask's outline (Fade)"]);
let exact = export(&d, &[g], Compat::Exact);
assert!(!exact.xml.contains("#2255FF"), "{}", exact.xml);
assert_eq!(
exact.warnings,
["layers under alpha or luminance masks were left out (exact mode) (Fade)", "nothing in it could be drawn: the drawable is empty (G)"]
);
let ignore = export(&d, &[g], Compat::Ignore);
assert!(ignore.xml.contains("<clip-path") && ignore.warnings.is_empty());
}
#[test]
fn what_has_no_equivalent_is_left_out_and_named_once_per_kind() {
let (mut d, p) = doc();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 200.0, 200.0).unwrap();
d.get_mut(f).unwrap().fills.clear();
for i in 0..5 {
let r = rect(&mut d, f, &format!("Shadow {i}"), 40.0 * i as f64, 0.0, 30.0, 30.0, "FF0000");
d.get_mut(r).unwrap().effects = vec![Effect::drop_shadow()];
}
let b = rect(&mut d, f, "", 0.0, 100.0, 30.0, 30.0, "00FF00");
d.get_mut(b).unwrap().blend = BlendMode::Multiply;
d.get_mut(b).unwrap().effects = vec![Effect::layer_blur()];
let img = rect(&mut d, f, "Photo", 100.0, 100.0, 30.0, 30.0, "000000");
d.get_mut(img).unwrap().fills = vec![Paint::image("abc", figcraft_color::ImageScaleMode::Fill)];
let out = export(&d, &[f], Compat::BestEffort);
assert_eq!(
out.warnings,
[
"drop shadows were left out (Shadow 0, Shadow 1, Shadow 2, 2 more)",
"layer blurs were left out (unnamed layer)",
"blend modes were drawn as normal (unnamed layer)",
"image fills were left out (Photo)",
]
);
// The shapes themselves are still drawn; the image fill isn't.
assert_eq!(fills(&out.xml).len(), 6, "{}", out.xml);
assert!(export(&d, &[f], Compat::Ignore).warnings.is_empty());
assert_eq!(export(&d, &[f], Compat::Exact).warnings.len(), 4);
}
#[test]
fn hidden_and_transparent_layers_paint_nothing() {
let (mut d, p) = doc();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(f).unwrap().fills.clear();
let a = rect(&mut d, f, "A", 0.0, 0.0, 10.0, 10.0, "FF0000");
let b = rect(&mut d, f, "B", 20.0, 0.0, 10.0, 10.0, "00FF00");
rect(&mut d, f, "C", 40.0, 0.0, 10.0, 10.0, "0000FF");
d.get_mut(a).unwrap().visible = false;
d.get_mut(b).unwrap().opacity = 0.0;
assert_eq!(fills(&export(&d, &[f], Compat::Exact).xml), ["#0000FF"]);
}
#[test]
fn huge_coordinates_are_left_out_with_a_warning() {
let (mut d, p) = doc();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(f).unwrap().clip = false;
d.get_mut(f).unwrap().fills.clear();
rect(&mut d, f, "Near", 0.0, 0.0, 10.0, 10.0, "FF0000");
rect(&mut d, f, "Far", 3.0e7, 0.0, 10.0, 10.0, "00FF00");
let opts = VdOptions { bounds: Some(figcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0)), ..Default::default() };
let out = export_with(&d, &[f], opts);
assert_eq!(fills(&out.xml), ["#FF0000"]);
assert_eq!(out.warnings, ["shapes with coordinates beyond ±10,000,000 were left out (Far)"]);
}
/// A group masked by an even-odd outline (an Exclude boolean ring as a vector mask).
fn ring_masked() -> (Document, NodeId) {
let (mut d, p) = doc();
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 60.0, 60.0).unwrap();
let bo = d.create(g, NodeKind::BooleanOp { op: figcraft_doc::BoolOp::Exclude }, "Ring", 0.0, 0.0, 1.0, 1.0).unwrap();
let a = rect(&mut d, bo, "A", 0.0, 0.0, 60.0, 60.0, "000000");
let b = rect(&mut d, bo, "B", 20.0, 20.0, 20.0, 20.0, "000000");
let _ = (a, b);
d.fit_to_children(bo).unwrap();
d.get_mut(bo).unwrap().is_mask = true;
d.get_mut(bo).unwrap().mask_type = MaskType::Vector;
rect(&mut d, g, "Content", 0.0, 0.0, 60.0, 60.0, "2255FF");
(d, g)
}
fn clip_of(xml: &str) -> BezPath {
let els = elements(xml);
let clips = tagged(&els, "clip-path");
assert_eq!(clips.len(), 1, "{xml}");
super::strokes::parse_path(clips[0].get("pathData").unwrap())
}
#[test]
fn even_odd_clips_per_mode() {
let (d, g) = ring_masked();
// bestEffort and exact both rewrite the outline: the hole stays out of the clip.
for compat in [Compat::BestEffort, Compat::Exact] {
let out = export(&d, &[g], compat);
let clip = clip_of(&out.xml);
assert_ne!(clip.winding(Point::new(5.0, 5.0)), 0, "{compat:?}");
assert_eq!(clip.winding(Point::new(30.0, 30.0)), 0, "{compat:?}: the hole stays out of the clip");
assert!(out.xml.contains("#2255FF"), "{compat:?}");
let want: &[&str] = if compat == Compat::BestEffort { &["even-odd clip outlines were rewritten as non-zero (Ring)"] } else { &[] };
assert_eq!(out.warnings, want, "{compat:?}");
}
// ignore writes the outline as it is (no rewrite), silently.
let out = export(&d, &[g], Compat::Ignore);
assert!(clip_of(&out.xml).winding(Point::new(5.0, 5.0)) != 0 && out.xml.contains("#2255FF"), "{}", out.xml);
assert!(out.warnings.is_empty());
}
#[test]
fn an_export_that_draws_nothing_says_so() {
let (mut d, p) = doc();
let r = rect(&mut d, p, "Spacer", 0.0, 0.0, 10.0, 10.0, "FF0000");
d.get_mut(r).unwrap().fills.clear();
let out = export(&d, &[r], Compat::BestEffort);
assert!(out.xml.contains("<vector"), "{}", out.xml);
assert_eq!(out.warnings, ["nothing in it could be drawn: the drawable is empty (Spacer)"]);
assert!(export(&d, &[r], Compat::Ignore).warnings.is_empty());
}
@@ -0,0 +1,269 @@
use super::*;
use crate::model::{Grad, GradKind};
use crate::paint::{GradOut, color_at, gradient, svd, turn_stops};
use figcraft_color::{ColorStop, Gradient, GradientKind};
use figcraft_geom::{Affine, Point};
fn two_stop(kind: GradientKind) -> Gradient {
let mut g = Gradient::default_for(kind, hex("FF0000"));
g.stops = vec![
ColorStop { position: 0.0, color: hex("FF0000") },
ColorStop { position: 0.6, color: hex("00FF00") },
ColorStop { position: 1.0, color: hex("0000FF") },
];
g
}
/// Skewed, mirrored handles: nothing a plain gradient element could hold.
fn skewed(kind: GradientKind) -> Gradient {
let mut g = two_stop(kind);
g.from = (0.35, 0.4);
g.to = (0.8, 0.55);
g.width = (0.25, 0.95);
g
}
/// Where Android samples a Vector Drawable gradient at viewport point `p` (sweep: from +x,
/// turning toward +y, a full turn 0 → 1).
fn vd_t(out: &GradOut, p: Point) -> Option<(f64, &Grad)> {
let (q, grad) = match out {
GradOut::Plain(g) => (p, g),
GradOut::Grouped { inverse, grad, .. } => (*inverse * p, grad),
_ => return None,
};
let t = match grad.kind {
GradKind::Linear { start, end } => {
let (dx, dy) = (end.0 - start.0, end.1 - start.1);
((q.x - start.0) * dx + (q.y - start.1) * dy) / (dx * dx + dy * dy)
}
GradKind::Radial { center, radius } => (q.x - center.0).hypot(q.y - center.1) / radius,
GradKind::Sweep { center } => (q.y - center.1).atan2(q.x - center.0).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU,
};
Some((t, grad))
}
/// FigCraft's own parameter at `p` for a gradient with frame `f` (viewport ← unit).
fn figcraft_t(kind: GradientKind, f: Affine, p: Point) -> f64 {
let u = f.inverse() * p;
match kind {
GradientKind::Linear => u.x,
GradientKind::Radial => u.x.hypot(u.y),
_ => u.y.atan2(u.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU,
}
}
fn layer_xfs() -> Vec<Affine> {
vec![
Affine::IDENTITY,
Affine::translate((40.0, 10.0)) * Affine::rotate(0.7),
Affine::translate((90.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0),
Affine::new([1.2, 0.3, -0.5, 0.8, 30.0, 20.0]),
]
}
#[test]
fn every_gradient_samples_where_figcraft_samples_it() {
let (w, h) = (100.0, 80.0);
for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular] {
for g in [two_stop(kind), skewed(kind)] {
for xf in layer_xfs() {
let out = gradient(&g, 1.0, xf, w, h, false);
let f = xf * crate::paint::frame(&g, w, h);
for i in 0..40 {
for j in 0..40 {
let p = xf * Point::new(w * (i as f64 + 0.5) / 40.0, h * (j as f64 + 0.5) / 40.0);
let want = figcraft_t(kind, f, p);
let (got, grad) = vd_t(&out, p).unwrap();
let stops: Vec<_> = g.sorted_stops().iter().map(|s| (f64::from(s.position), s.color)).collect();
let (a, b) = (color_at(&stops, want), color_at(&grad.stops, got));
// Away from the sweep's seam a turn's worth either side is the same colour.
let near_seam = kind == GradientKind::Angular && (want < 1e-3 || want > 1.0 - 1e-3);
if !near_seam {
assert!(
(a.r - b.r).abs() < 1e-3 && (a.g - b.g).abs() < 1e-3 && (a.b - b.b).abs() < 1e-3,
"{kind:?} {xf:?} at {p:?}: {want} vs {got}"
);
}
}
}
}
}
}
}
#[test]
fn the_written_gradient_matches_the_rendered_pixels() {
for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular] {
let (mut d, p) = doc();
let r = rect(&mut d, p, "R", 0.0, 0.0, 100.0, 80.0, "000000");
d.get_mut(r).unwrap().fills = vec![Paint::gradient(skewed(kind))];
let area = figcraft_render::export_bounds(&d, r).unwrap();
let pix = figcraft_render::render_area(&d, &[r], area, 1.0).unwrap();
let rgba = figcraft_render::to_rgba8(&pix);
let out = gradient(&skewed(kind), 1.0, Affine::translate((-area.x0, -area.y0)) * d.world_transform(r), 100.0, 80.0, false);
let f = Affine::translate((-area.x0, -area.y0)) * d.world_transform(r) * crate::paint::frame(&skewed(kind), 100.0, 80.0);
let mut checked = 0;
for y in (2..78).step_by(3) {
for x in (2..98).step_by(3) {
let pt = Point::new(x as f64 + 0.5, y as f64 + 0.5);
let ft = figcraft_t(kind, f, pt);
if kind == GradientKind::Angular && (ft < 0.03 || ft > 0.97 || (ft - 0.6).abs() < 0.01) {
continue;
}
let (t, grad) = vd_t(&out, pt).unwrap();
let c = color_at(&grad.stops, t);
let i = (y * pix.width() as usize + x) * 4;
let px = &rgba[i..i + 3];
let want = [c.r, c.g, c.b].map(|v| (v * 255.0).round());
for k in 0..3 {
assert!((f32::from(px[k]) - want[k]).abs() <= 6.0, "{kind:?} at ({x},{y}): rendered {px:?}, VD {want:?}");
}
checked += 1;
}
}
assert!(checked > 500, "{kind:?}: {checked}");
}
}
#[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 = vec![(0.0, hex("FF0000")), (0.25, hex("00FF00")), (0.7, hex("0000FF")), (1.0, hex("FFFF00"))];
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 = i as f64 / 200.0;
let moved = (t + shift).rem_euclid(1.0);
if moved < 1e-6 || moved > 1.0 - 1e-6 {
continue;
}
let (a, b) = (color_at(&stops, t), color_at(&turned, moved));
assert!((a.r - b.r).abs() < 1e-4 && (a.g - b.g).abs() < 1e-4 && (a.b - b.b).abs() < 1e-4, "{shift}: {t}");
}
}
assert_eq!(turn_stops(&stops, 0.0), stops);
assert_eq!(turn_stops(&stops, 2.0), stops);
}
#[test]
fn a_collapsed_frame_paints_nothing_and_one_stop_is_solid() {
let mut g = two_stop(GradientKind::Radial);
g.width = g.from;
assert_eq!(gradient(&g, 1.0, Affine::IDENTITY, 10.0, 10.0, false), GradOut::Nothing);
let mut one = two_stop(GradientKind::Linear);
one.stops.truncate(1);
assert_eq!(gradient(&one, 0.5, Affine::IDENTITY, 10.0, 10.0, false), GradOut::Solid(hex("FF000080").with_alpha(0.5)));
}
#[test]
fn a_linear_gradient_is_an_aapt_attr_with_paint_opacity_in_its_stops() {
let (mut d, p) = doc();
let r = rect(&mut d, p, "R", 0.0, 0.0, 100.0, 50.0, "000000");
let mut paint = Paint::gradient(two_stop(GradientKind::Linear));
paint.opacity = 0.5;
d.get_mut(r).unwrap().fills = vec![paint];
let out = export(&d, &[r], Compat::Exact);
assert!(out.xml.contains("xmlns:aapt=\"http://schemas.android.com/aapt\""), "{}", out.xml);
let els = elements(&out.xml);
let attr = tagged(&els, "attr");
assert_eq!(attr[0].get("name"), Some("android:fillColor"));
let g = tagged(&els, "gradient")[0];
assert_eq!(g.get("type"), Some("linear"));
assert!(g.get("tileMode").is_none(), "clamp is the default");
// Default handles: top centre to bottom centre.
assert_eq!([g.get("startX"), g.get("startY"), g.get("endX"), g.get("endY")], [Some("50"), Some("0"), Some("50"), Some("50")]);
let items: Vec<_> = tagged(&els, "item").iter().map(|e| (e.get("offset").unwrap().to_string(), e.get("color").unwrap().to_string())).collect();
assert_eq!(items, [("0".into(), "#80FF0000".into()), ("0.6".into(), "#8000FF00".into()), ("1".into(), "#800000FF".into())]);
assert!(tagged(&els, "path")[0].get("fillColor").is_none());
}
#[test]
fn a_radial_gradient_sits_in_a_group_that_maps_the_unit_circle() {
let (mut d, p) = doc();
let r = rect(&mut d, p, "R", 0.0, 0.0, 100.0, 50.0, "000000");
d.get_mut(r).unwrap().fills = vec![Paint::gradient(two_stop(GradientKind::Radial))];
let out = export(&d, &[r], Compat::Exact);
let els = elements(&out.xml);
let gr = tagged(&els, "group")[0];
// Centre (50, 25), radii 50 × 25 (an axis-aligned frame: no rotation).
assert_eq!([gr.get("translateX"), gr.get("translateY")], [Some("50"), Some("25")]);
let (sx, sy) = (gr.get("scaleX").unwrap().parse::<f64>().unwrap(), gr.get("scaleY").unwrap().parse::<f64>().unwrap());
assert!(close(sx.abs(), 50.0) && close(sy.abs(), 25.0), "{sx} {sy}");
let g = tagged(&els, "gradient")[0];
assert_eq!([g.get("type"), g.get("centerX"), g.get("centerY"), g.get("gradientRadius")], [Some("radial"), Some("0"), Some("0"), Some("1")]);
// The path is in the group's space: mapped back it covers the rectangle.
let (x0, y0, x1, y1) = bounds(tagged(&els, "path")[0].get("pathData").unwrap());
let rot = gr.get("rotation").map_or(0.0, |v| v.parse::<f64>().unwrap()).to_radians();
let m = Affine::translate((50.0, 25.0)) * Affine::rotate(rot) * Affine::scale_non_uniform(sx, sy);
let b = m.transform_rect_bbox(figcraft_geom::Rect::new(x0, y0, x1, y1));
assert!(close(b.x0, 0.0) && close(b.y0, 0.0) && (b.x1 - 100.0).abs() < 0.05 && (b.y1 - 50.0).abs() < 0.05, "{b:?}");
}
#[test]
fn diamond_gradients_per_mode() {
let (mut d, p) = doc();
let r = rect(&mut d, p, "Gem", 0.0, 0.0, 40.0, 40.0, "000000");
d.get_mut(r).unwrap().fills = vec![Paint::solid(hex("FFFFFF")), Paint::gradient(two_stop(GradientKind::Diamond))];
let best = export(&d, &[r], Compat::BestEffort);
assert!(best.xml.contains("android:type=\"radial\""), "{}", best.xml);
assert_eq!(best.warnings, ["diamond gradients were drawn as radial gradients (Gem)"]);
let exact = export(&d, &[r], Compat::Exact);
assert!(!exact.xml.contains("gradient") && exact.xml.contains("#FFFFFF"), "only the gradient goes:\n{}", exact.xml);
assert!(!exact.xml.contains("xmlns:aapt"));
assert_eq!(exact.warnings, ["diamond gradients were left out (exact mode) (Gem)"]);
let ignore = export(&d, &[r], Compat::Ignore);
assert!(!ignore.xml.contains("gradient") && ignore.warnings.is_empty(), "{:?}", ignore.warnings);
}
#[test]
fn a_sweep_is_written_as_a_sweep_gradient_with_its_seam() {
// Default angular handles start at 3 o'clock; red, yellow at a quarter, blue at the end.
let (mut d, p) = doc();
let r = rect(&mut d, p, "Wheel", 0.0, 0.0, 100.0, 100.0, "000000");
let mut g = two_stop(GradientKind::Angular);
g.stops = vec![
ColorStop { position: 0.0, color: hex("FF0000") },
ColorStop { position: 0.25, color: hex("FFFF00") },
ColorStop { position: 1.0, color: hex("0000FF") },
];
d.get_mut(r).unwrap().fills = vec![figcraft_color::Paint::gradient(g)];
let out = export(&d, &[r], Compat::BestEffort);
let els = elements(&out.xml);
let grads = tagged(&els, "gradient");
assert_eq!(grads.len(), 1, "{}", out.xml);
assert_eq!(grads[0].get("type"), Some("sweep"));
assert_eq!((grads[0].get("centerX"), grads[0].get("centerY")), (Some("0"), Some("0")));
// In a group that moves the unit gradient onto the layer.
assert!(tagged(&els, "group").iter().any(|e| e.get("translateX").is_some()), "{}", out.xml);
let items: Vec<(f64, String)> =
tagged(&els, "item").iter().map(|e| (e.get("offset").unwrap().parse().unwrap(), e.get("color").unwrap().to_string())).collect();
assert_eq!(items.first().map(|i| i.0), Some(0.0), "{items:?}");
assert_eq!(items.last().map(|i| i.0), Some(1.0), "{items:?}");
assert!(items.windows(2).all(|w| w[0].0 <= w[1].0), "offsets go up: {items:?}");
assert!(items.windows(2).all(|w| w[0] != w[1]), "no repeated stops: {items:?}");
// The seam: blue (the end) then red (the start) at the same offset.
assert!(items.windows(2).any(|w| w[0].0 == w[1].0 && w[0].1 == "#0000FF" && w[1].1 == "#FF0000"), "{items:?}");
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
}
#[test]
fn turned_stops_on_the_seam_are_not_repeated() {
let (r, b) = (hex("FF0000"), hex("0000FF"));
let out = turn_stops(&[(0.0, r), (0.5, b), (1.0, r)], 0.5);
assert!(out.windows(2).all(|w| w[0] != w[1]), "{out:?}");
}
+99
View File
@@ -0,0 +1,99 @@
//! Unit tests: documents built in code, the written XML parsed back (every test checks it's
//! well-formed) and compared by structure and numbers.
mod compat;
mod gradients;
mod shapes;
mod strokes;
mod xml_out;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, NodeId, NodeKind};
use crate::{Compat, VdExport, VdOptions, try_export};
pub(super) fn hex(s: &str) -> Color {
Color::from_hex(s).unwrap()
}
pub(super) fn doc() -> (Document, NodeId) {
let d = Document::new("vd");
let p = d.first_page().unwrap();
(d, p)
}
pub(super) fn rect(d: &mut Document, parent: NodeId, name: &str, x: f64, y: f64, w: f64, h: f64, fill: &str) -> NodeId {
let r = d.create(parent, NodeKind::Rectangle, name, x, y, w, h).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::solid(hex(fill))];
r
}
pub(super) fn ellipse() -> NodeKind {
NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }
}
pub(super) fn export(d: &Document, ids: &[NodeId], compat: Compat) -> VdExport {
export_with(d, ids, VdOptions { compat, ..Default::default() })
}
pub(super) fn export_with(d: &Document, ids: &[NodeId], opts: VdOptions) -> VdExport {
let out = try_export(d, ids, &opts).unwrap();
// Always well-formed, never a non-finite number.
if let Err(e) = roxmltree::Document::parse(&out.xml) {
panic!("not well-formed: {e}\n{}", out.xml);
}
for bad in ["NaN", "inf", "=\"-0\"", "e+", "e-"] {
assert!(!out.xml.contains(bad), "{bad} in\n{}", out.xml);
}
out
}
/// An element as (tag name, attributes without namespace prefixes).
#[derive(Debug, Clone)]
pub(super) struct El {
pub tag: String,
pub attrs: std::collections::BTreeMap<String, String>,
}
impl El {
pub fn get(&self, k: &str) -> Option<&str> {
self.attrs.get(k).map(String::as_str)
}
}
/// Every element of `xml`, document order.
pub(super) fn elements(xml: &str) -> Vec<El> {
let doc = roxmltree::Document::parse(xml).unwrap();
doc.descendants()
.filter(|n| n.is_element())
.map(|n| El { tag: n.tag_name().name().to_string(), attrs: n.attributes().map(|a| (a.name().to_string(), a.value().to_string())).collect() })
.collect()
}
pub(super) fn tagged<'a>(els: &'a [El], tag: &str) -> Vec<&'a El> {
els.iter().filter(|e| e.tag == tag).collect()
}
/// The numbers of a path data string, in order.
pub(super) fn numbers(data: &str) -> Vec<f64> {
data.split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).filter(|s| !s.is_empty()).map(|s| s.parse().unwrap()).collect()
}
/// The (x, y) bounding box of a path data string's points.
pub(super) fn bounds(data: &str) -> (f64, f64, f64, f64) {
let n = numbers(data);
let xs = n.iter().step_by(2);
let ys = n.iter().skip(1).step_by(2);
let (x0, x1) = xs.fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(*v), b.max(*v)));
let (y0, y1) = ys.fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(*v), b.max(*v)));
(x0, y0, x1, y1)
}
pub(super) fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-3
}
/// The `<path>` elements of `xml`.
pub(super) fn paths_of(xml: &str) -> Vec<El> {
elements(xml).into_iter().filter(|e| e.tag == "path").collect()
}
+210
View File
@@ -0,0 +1,210 @@
use super::*;
use crate::xml::path_data;
use figcraft_doc::BoolOp;
use figcraft_geom::Affine;
#[test]
fn a_rectangle_is_a_drawable_its_size_in_dp() {
let (mut d, p) = doc();
let r = rect(&mut d, p, "R", 10.0, 20.0, 30.0, 40.0, "FF0000");
let out = export(&d, &[r], Compat::BestEffort);
let els = elements(&out.xml);
let v = &els[0];
assert_eq!(v.tag, "vector");
assert_eq!((v.get("width"), v.get("height")), (Some("30dp"), Some("40dp")));
assert_eq!((v.get("viewportWidth"), v.get("viewportHeight")), (Some("30"), Some("40")));
assert!(!out.xml.contains("xmlns:aapt"), "no gradient, no aapt namespace");
let paths = tagged(&els, "path");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].get("fillColor"), Some("#FF0000"));
assert_eq!(bounds(paths[0].get("pathData").unwrap()), (0.0, 0.0, 30.0, 40.0));
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
}
#[test]
fn colour_alpha_and_paint_opacity_go_into_aarrggbb() {
let (mut d, p) = doc();
let r = rect(&mut d, p, "R", 0.0, 0.0, 10.0, 10.0, "00FF0080");
d.get_mut(r).unwrap().fills[0].opacity *= 0.5;
let out = export(&d, &[r], Compat::BestEffort);
// 0x80/255 × 0.5 ≈ 0.251 → 0x40.
assert_eq!(paths_of(&out.xml)[0].get("fillColor"), Some("#4000FF00"));
}
#[test]
fn several_fills_are_paths_bottom_to_top_and_hidden_ones_are_left_out() {
let (mut d, p) = doc();
let r = rect(&mut d, p, "R", 0.0, 0.0, 10.0, 10.0, "FF0000");
let mut hidden = Paint::solid(hex("0000FF"));
hidden.visible = false;
d.get_mut(r).unwrap().fills.extend([Paint::solid(hex("00FF00")), hidden]);
let out = export(&d, &[r], Compat::BestEffort);
let fills: Vec<_> = paths_of(&out.xml).iter().map(|e| e.get("fillColor").unwrap().to_string()).collect();
assert_eq!(fills, ["#FF0000", "#00FF00"]);
}
#[test]
fn shapes_are_their_render_outlines_with_the_transform_baked_in() {
let (mut d, p) = doc();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 300.0).unwrap();
d.get_mut(f).unwrap().fills.clear();
let kinds = [ellipse(), NodeKind::Star { count: 5, inner_ratio: 0.4 }, NodeKind::Polygon { count: 6 }, NodeKind::Rectangle];
let mut ids = Vec::new();
for (i, k) in kinds.into_iter().enumerate() {
let id = d.create(f, k, "S", 20.0 + 60.0 * i as f64, 30.0, 50.0, 40.0).unwrap();
let n = d.get_mut(id).unwrap();
n.fills = vec![Paint::solid(hex("336699"))];
n.corners.radii = [8.0, 4.0, 0.0, 12.0];
// Rotated, flipped and sheared: none of it fits a <group>, all of it is in the path.
n.transform *= Affine::new([0.9, 0.3, 0.4, -1.1, 0.0, 40.0]);
ids.push(id);
}
let out = export(&d, &[f], Compat::Ignore);
let paths = paths_of(&out.xml);
assert_eq!(paths.len(), ids.len());
let origin = Affine::translate((0.0, 0.0));
for (id, el) in ids.iter().zip(&paths) {
let n = d.node(*id).unwrap();
let (mut outline, _) = figcraft_render::node_outline(&d, n).unwrap();
outline.apply_affine(origin * d.world_transform(*id));
assert_eq!(el.get("pathData").unwrap(), path_data(&outline));
}
}
#[test]
fn nested_groups_rotations_and_flips_are_baked() {
let (mut d, p) = doc();
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
let r = rect(&mut d, g, "R", 10.0, 10.0, 40.0, 20.0, "000000");
d.get_mut(g).unwrap().transform = Affine::translate((50.0, 50.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 2.0);
let area = figcraft_render::export_bounds(&d, g).unwrap();
let out = export(&d, &[g], Compat::BestEffort);
let data = paths_of(&out.xml)[0].get("pathData").unwrap().to_string();
let n = d.node(r).unwrap();
let (mut outline, _) = figcraft_render::node_outline(&d, n).unwrap();
outline.apply_affine(Affine::translate((-area.x0, -area.y0)) * d.world_transform(r));
assert_eq!(data, path_data(&outline));
assert!(!out.xml.contains("<group"), "no group needed:\n{}", out.xml);
}
#[test]
fn a_boolean_is_its_result_path() {
let (mut d, p) = doc();
let a = rect(&mut d, p, "A", 0.0, 0.0, 60.0, 60.0, "000000");
let b = d.create(p, ellipse(), "B", 30.0, 30.0, 50.0, 50.0).unwrap();
let bo = d.create(p, NodeKind::BooleanOp { op: BoolOp::Subtract }, "Bool", 0.0, 0.0, 1.0, 1.0).unwrap();
d.reparent(a, bo, None).unwrap();
d.reparent(b, bo, None).unwrap();
d.fit_to_children(bo).unwrap();
d.get_mut(bo).unwrap().fills = vec![Paint::solid(hex("CC2244"))];
let out = export(&d, &[bo], Compat::Exact);
let paths = paths_of(&out.xml);
assert_eq!(paths.len(), 1, "operands aren't drawn on their own:\n{}", out.xml);
assert_eq!(paths[0].get("fillColor"), Some("#CC2244"));
assert_eq!(paths[0].get("fillType"), Some("evenOdd"), "exact keeps the boolean's even-odd rule");
}
#[test]
fn a_clipping_frame_is_a_group_with_its_clip_path_first() {
let (mut d, p) = doc();
let f = rect(&mut d, p, "F", 0.0, 0.0, 100.0, 100.0, "FFFFFF");
d.get_mut(f).unwrap().kind = NodeKind::Frame;
d.get_mut(f).unwrap().clip = true;
d.get_mut(f).unwrap().corners.radii = [10.0; 4];
rect(&mut d, f, "Over", 50.0, 50.0, 100.0, 100.0, "FF0000");
let out = export(&d, &[f], Compat::BestEffort);
let els = elements(&out.xml);
let tags: Vec<&str> = els.iter().map(|e| e.tag.as_str()).collect();
assert_eq!(tags, ["vector", "path", "group", "clip-path", "path"], "{}", out.xml);
let clip = tagged(&els, "clip-path")[0].get("pathData").unwrap();
assert_eq!(bounds(clip), (0.0, 0.0, 100.0, 100.0));
assert!(clip.contains('C'), "rounded corners clip too: {clip}");
}
#[test]
fn an_unclipped_frame_draws_its_children_without_a_group() {
let (mut d, p) = doc();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(f).unwrap().clip = false;
d.get_mut(f).unwrap().fills.clear();
rect(&mut d, f, "A", 10.0, 10.0, 10.0, 10.0, "FF0000");
let out = export(&d, &[f], Compat::BestEffort);
assert!(!out.xml.contains("<group"), "{}", out.xml);
}
#[test]
fn a_vector_mask_clips_the_layers_above_it_and_isnt_drawn() {
let (mut d, p) = doc();
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
let m = d.create(g, ellipse(), "Mask", 10.0, 10.0, 80.0, 80.0).unwrap();
{
let n = d.get_mut(m).unwrap();
n.is_mask = true;
n.mask_type = figcraft_doc::MaskType::Vector;
n.fills = vec![Paint::solid(hex("00FF00"))];
}
rect(&mut d, g, "Content", 0.0, 0.0, 100.0, 100.0, "2255FF");
let out = export(&d, &[g], Compat::Exact);
let els = elements(&out.xml);
let tags: Vec<&str> = els.iter().map(|e| e.tag.as_str()).collect();
assert_eq!(tags, ["vector", "group", "clip-path", "path"], "{}", out.xml);
assert!(!out.xml.contains("#00FF00"), "the mask itself doesn't paint");
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
}
#[test]
fn text_is_glyph_outlines_one_path_per_styled_run() {
use figcraft_doc::{StyleRun, TextData};
figcraft_text::scan_system_fonts();
let (mut d, p) = doc();
let mut text = TextData::new("ab");
text.style.font_size = 24.0;
let mut style = text.style.clone();
style.fills = Some(vec![Paint::solid(hex("00FF00"))]);
text.runs.push(StyleRun { start: 1, end: 2, style });
text.normalize();
let t = d.create(p, NodeKind::Text(text), "Text", 0.0, 0.0, 100.0, 40.0).unwrap();
d.get_mut(t).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let out = export(&d, &[t], Compat::BestEffort);
let fills: Vec<_> = paths_of(&out.xml).iter().map(|e| e.get("fillColor").unwrap().to_string()).collect();
assert_eq!(fills, ["#000000", "#00FF00"], "{}", out.xml);
}
#[test]
fn nothing_to_export_is_an_error() {
let (mut d, p) = doc();
assert_eq!(try_export(&d, &[], &VdOptions::default()), Err(crate::VdError::Empty));
let r = rect(&mut d, p, "R", 0.0, 0.0, 10.0, 10.0, "000000");
let flat = VdOptions { bounds: Some(figcraft_geom::Rect::new(5.0, 5.0, 5.0, 20.0)), ..Default::default() };
assert_eq!(try_export(&d, &[r], &flat), Err(crate::VdError::Empty));
}
#[test]
fn an_area_shifts_the_viewport_origin() {
let (mut d, p) = doc();
let r = rect(&mut d, p, "R", 10.0, 10.0, 10.0, 10.0, "000000");
let opts = VdOptions { bounds: Some(figcraft_geom::Rect::new(0.0, 0.0, 50.0, 25.0)), ..Default::default() };
let out = export_with(&d, &[r], opts);
let els = elements(&out.xml);
assert_eq!(els[0].get("width"), Some("50dp"));
assert_eq!(bounds(tagged(&els, "path")[0].get("pathData").unwrap()), (10.0, 10.0, 20.0, 20.0));
}
#[test]
fn depth_past_the_limit_is_refused_and_at_the_limit_kept() {
use figcraft_render::{ExportDepthError, MAX_EXPORT_DEPTH};
let nested = |depth: usize| {
let (mut d, p) = doc();
let root = d.create(p, NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
let mut parent = root;
for _ in 1..depth {
parent = d.create(parent, NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
}
let leaf = rect(&mut d, parent, "deep-leaf", 3.0, 5.0, 13.0, 17.0, "FF0000");
(d, root, leaf)
};
let (d, root, _) = nested(MAX_EXPORT_DEPTH);
assert!(export(&d, &[root], Compat::BestEffort).xml.contains("#FF0000"));
let (d, root, leaf) = nested(MAX_EXPORT_DEPTH + 1);
assert_eq!(try_export(&d, &[root], &VdOptions::default()), Err(crate::VdError::Depth(ExportDepthError { root, node: leaf })));
}
+152
View File
@@ -0,0 +1,152 @@
use super::*;
use figcraft_doc::{StrokeAlign, StrokeCap, StrokeJoin};
use figcraft_geom::Affine;
fn stroked(align: StrokeAlign) -> (Document, NodeId) {
let (mut d, p) = doc();
let r = d.create(p, NodeKind::Rectangle, "R", 10.0, 10.0, 40.0, 30.0).unwrap();
let n = d.get_mut(r).unwrap();
n.fills.clear();
n.strokes = vec![Paint::solid(hex("112233"))];
n.stroke.weight = 4.0;
n.stroke.align = align;
(d, r)
}
#[test]
fn a_centred_stroke_is_drawn_by_android() {
let (mut d, r) = stroked(StrokeAlign::Center);
{
let n = d.get_mut(r).unwrap();
n.stroke.join = StrokeJoin::Bevel;
n.stroke.cap_start = StrokeCap::Round;
n.stroke.cap_end = StrokeCap::Round;
}
let out = export(&d, &[r], Compat::BestEffort);
let paths = paths_of(&out.xml);
assert_eq!(paths.len(), 1, "{}", out.xml);
let s = &paths[0];
assert_eq!((s.get("strokeColor"), s.get("strokeWidth")), (Some("#112233"), Some("4")));
assert_eq!((s.get("strokeLineCap"), s.get("strokeLineJoin")), (Some("round"), Some("bevel")));
assert!(s.get("fillColor").is_none() && s.get("strokeMiterLimit").is_none());
// The viewport grows by half the stroke on each side.
assert_eq!(elements(&out.xml)[0].get("width"), Some("44dp"));
assert_eq!(bounds(s.get("pathData").unwrap()), (2.0, 2.0, 42.0, 32.0));
}
#[test]
fn a_miter_limit_other_than_four_is_written() {
let (mut d, r) = stroked(StrokeAlign::Center);
d.get_mut(r).unwrap().stroke.miter_limit = 10.0;
let out = export(&d, &[r], Compat::BestEffort);
assert_eq!(paths_of(&out.xml)[0].get("strokeMiterLimit"), Some("10"));
}
#[test]
fn a_uniform_scale_scales_the_stroke_width() {
let (mut d, r) = stroked(StrokeAlign::Center);
let n = d.get_mut(r).unwrap();
n.transform *= Affine::rotate(0.4) * Affine::scale(2.5);
let out = export(&d, &[r], Compat::BestEffort);
assert_eq!(paths_of(&out.xml)[0].get("strokeWidth"), Some("10"));
}
#[test]
fn strokes_android_cant_draw_are_their_filled_outline() {
type Edit = fn(&mut figcraft_doc::Node);
let cases: [(&str, Edit); 4] = [
("inside", |n| n.stroke.align = StrokeAlign::Inside),
("outside", |n| n.stroke.align = StrokeAlign::Outside),
("dashed", |n| n.stroke.dashes = vec![6.0, 3.0]),
("stretched", |n| n.transform *= Affine::scale_non_uniform(2.0, 1.0)),
];
for (what, edit) in cases {
let (mut d, r) = stroked(StrokeAlign::Center);
edit(d.get_mut(r).unwrap());
for compat in [Compat::BestEffort, Compat::Exact, Compat::Ignore] {
let out = export(&d, &[r], compat);
let paths = paths_of(&out.xml);
assert!(!out.xml.contains("strokeColor"), "{what}:\n{}", out.xml);
assert!(!paths.is_empty() && paths.iter().all(|p| p.get("fillColor") == Some("#112233")), "{what}:\n{}", out.xml);
// Rings fill even-odd in FigCraft: rewritten, or kept as the rule in exact/ignore.
let ring = what == "inside" || what == "outside";
let kept = paths.iter().any(|p| p.get("fillType") == Some("evenOdd"));
match compat {
Compat::BestEffort => assert!(!kept, "{what}: rewritten\n{}", out.xml),
_ => assert_eq!(kept, ring, "{what} {compat:?}\n{}", out.xml),
}
if compat == Compat::BestEffort && ring {
assert_eq!(out.warnings, ["even-odd fills were rewritten as non-zero (R)"], "{what}");
} else {
assert!(out.warnings.is_empty(), "{what} {compat:?}: {:?}", out.warnings);
}
}
}
}
#[test]
fn an_inside_stroke_outline_covers_only_the_ring() {
let (d, r) = stroked(StrokeAlign::Inside);
let out = export(&d, &[r], Compat::BestEffort);
let data = paths_of(&out.xml)[0].get("pathData").unwrap().to_string();
let path = parse_path(&data);
use figcraft_geom::Shape;
// Viewport = the rectangle itself (inside strokes don't grow it): (0,0)–(40,30).
assert_ne!(path.winding((1.0, 1.0).into()), 0, "on the ring");
assert_eq!(path.winding((20.0, 15.0).into()), 0, "the hole");
assert_eq!(path.winding((-1.0, 15.0).into()), 0, "outside");
}
#[test]
fn a_line_stroke_is_native_with_its_caps() {
let (mut d, p) = doc();
let l = d.create(p, NodeKind::Line, "L", 0.0, 0.0, 50.0, 0.0).unwrap();
let n = d.get_mut(l).unwrap();
n.strokes = vec![Paint::solid(hex("000000"))];
n.stroke.weight = 2.0;
n.stroke.cap_start = StrokeCap::Square;
n.stroke.cap_end = StrokeCap::Square;
let out = export(&d, &[l], Compat::BestEffort);
let s = paths_of(&out.xml)[0].clone();
assert_eq!((s.get("strokeLineCap"), s.get("strokeWidth")), (Some("square"), Some("2")), "{}", out.xml);
}
#[test]
fn a_gradient_stroke_is_an_outline_filled_with_the_gradient() {
let (mut d, r) = stroked(StrokeAlign::Center);
d.get_mut(r).unwrap().strokes = vec![Paint::gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, hex("FF0000")))];
let out = export(&d, &[r], Compat::BestEffort);
assert!(!out.xml.contains("strokeColor") && out.xml.contains("android:type=\"linear\""), "{}", out.xml);
}
/// Parse path data as written ("M0,0 L1,0 C… Z") back into a path.
pub(super) fn parse_path(s: &str) -> figcraft_geom::BezPath {
let mut p = figcraft_geom::BezPath::new();
let mut cmd = ' ';
let mut pts: Vec<figcraft_geom::Point> = Vec::new();
let flush = |cmd: char, pts: &mut Vec<figcraft_geom::Point>, p: &mut figcraft_geom::BezPath| {
match (cmd, pts.as_slice()) {
(' ', []) => {}
('M', [a]) => p.move_to(*a),
('L', [a]) => p.line_to(*a),
('Q', [a, b]) => p.quad_to(*a, *b),
('C', [a, b, c]) => p.curve_to(*a, *b, *c),
('Z', []) => p.close_path(),
other => panic!("bad segment {other:?} in {s}"),
}
pts.clear();
};
for tok in s.split(' ') {
let mut rest = tok;
if let Some(c) = tok.chars().next().filter(char::is_ascii_alphabetic) {
flush(cmd, &mut pts, &mut p);
cmd = c;
rest = &tok[1..];
}
if let Some((x, y)) = rest.split_once(',') {
pts.push(figcraft_geom::Point::new(x.parse().unwrap(), y.parse().unwrap()));
}
}
flush(cmd, &mut pts, &mut p);
p
}
@@ -0,0 +1,88 @@
use super::*;
use crate::xml::{color, esc, num};
#[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");
}
#[test]
fn colours_are_rrggbb_or_aarrggbb() {
assert_eq!(color(hex("1A2B3C")), "#1A2B3C");
assert_eq!(color(hex("1A2B3C80")), "#801A2B3C");
assert_eq!(color(Color::new(2.0, -1.0, f32::NAN, 1.0)).len(), 7);
}
#[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 names_are_written_only_when_asked_unique_and_round_trip() {
let (mut d, p) = doc();
let g = d.create(p, NodeKind::Group, "Icon & \"co\" <1>", 0.0, 0.0, 50.0, 50.0).unwrap();
rect(&mut d, g, "dot", 0.0, 0.0, 10.0, 10.0, "FF0000");
rect(&mut d, g, "dot", 20.0, 0.0, 10.0, 10.0, "00FF00");
rect(&mut d, g, " ", 40.0, 0.0, 10.0, 10.0, "0000FF");
assert!(!export(&d, &[g], Compat::BestEffort).xml.contains("android:name"));
let out = export_with(&d, &[g], VdOptions { include_names: true, ..Default::default() });
let els = elements(&out.xml);
let names: Vec<_> = els.iter().filter_map(|e| e.get("name")).collect();
assert_eq!(names, ["Icon & \"co\" <1>", "dot", "dot_2", "layer"], "{}", out.xml);
assert_eq!(tagged(&els, "group")[0].get("name"), Some("Icon & \"co\" <1>"), "a container is a named group");
}
#[test]
fn a_long_name_is_cut_at_200_characters() {
let (mut d, p) = doc();
let long = "é".repeat(300);
let r = rect(&mut d, p, &long, 0.0, 0.0, 10.0, 10.0, "FF0000");
let out = export_with(&d, &[r], VdOptions { include_names: true, ..Default::default() });
assert_eq!(paths_of(&out.xml)[0].get("name").unwrap().chars().count(), 200);
}
#[test]
fn the_file_is_pretty_printed() {
let (mut d, p) = doc();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 24.0, 24.0).unwrap();
d.get_mut(f).unwrap().fills.clear();
rect(&mut d, f, "R", 4.0, 4.0, 16.0, 8.0, "FF0000");
let out = export(&d, &[f], Compat::BestEffort);
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!(out.xml, want);
}
#[test]
fn an_empty_area_is_still_a_drawable_android_loads() {
let (mut d, p) = doc();
let r = rect(&mut d, p, "R", 0.0, 0.0, 10.0, 10.0, "FF0000");
d.get_mut(r).unwrap().fills.clear();
let opts = VdOptions { bounds: Some(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)), ..Default::default() };
let out = export_with(&d, &[r], opts);
// Android throws "no path defined" for a <vector> without a <path>.
assert!(out.xml.ends_with("android:viewportHeight=\"10\">\n <path android:pathData=\"M0,0\" />\n</vector>\n"), "{}", out.xml);
}
+30
View File
@@ -0,0 +1,30 @@
//! Text layers as glyph outlines, one path per styled run.
use figcraft_color::Paint;
use figcraft_doc::{Node, NodeKind};
use figcraft_geom::{Affine, BezPath};
/// Text layer `n`'s glyph and decoration outlines grouped by style, in layer space, each with the
/// fills it paints in (its own, else the layer's). `None` for other layers and text that doesn't
/// lay out.
pub(crate) fn runs(n: &Node) -> Option<Vec<(BezPath, Vec<Paint>)>> {
let NodeKind::Text(_) = &n.kind else { return None };
let (layout, dy) = figcraft_text::layout_node(n)?;
let mut by_style: std::collections::BTreeMap<usize, BezPath> = std::collections::BTreeMap::new();
for g in &layout.glyphs {
by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied());
}
for d in &layout.decorations {
by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied());
}
Some(
by_style
.into_iter()
.map(|(k, mut path)| {
path.apply_affine(Affine::translate((0.0, dy)));
let fills = layout.styles.get(k).and_then(|s| s.fills.clone()).unwrap_or_else(|| n.fills.clone());
(path, fills)
})
.collect(),
)
}
+406
View File
@@ -0,0 +1,406 @@
//! The layer walk: what each layer paints, bottom to top, as paths and groups in viewport space.
//!
//! The order follows the renderer: a layer's fills, its children (clipped by a clipping frame,
//! masked runs clipped by their mask), then its strokes. A layer's opacity is folded into the
//! colours of what it paints; that is exact unless two of those shapes overlap (a Vector Drawable
//! group has no opacity of its own), which [`Compat`] decides.
use figcraft_color::{BlendMode, Paint, PaintKind};
use figcraft_doc::{Document, EffectKind, MaskType, Node, NodeId, NodeKind};
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use figcraft_render::{FillRule, MAX_EXPORT_DEPTH};
use crate::model::{Fill, GroupItem, GroupXf, Item, ItemKind, NativeStroke, PathItem};
use crate::paint::{GradOut, gradient, nonzero};
use crate::warnings::{Warn, Warnings};
use crate::{Compat, VdOptions, stroke, text};
/// The largest coordinate written: past it a 32-bit float (what Android parses path data into)
/// can't place points to a fraction of a dp.
const MAX_COORD: f64 = 1e7;
pub(crate) struct Walker<'a> {
doc: &'a Document,
opts: &'a VdOptions,
warn: Warnings,
names: std::collections::BTreeSet<String>,
}
/// Every coordinate of `p` finite and within [`MAX_COORD`].
fn writable(p: &BezPath) -> bool {
use figcraft_geom::kurbo::PathEl::*;
let ok = |q: figcraft_geom::Point| q.x.is_finite() && q.y.is_finite() && q.x.abs() <= MAX_COORD && q.y.abs() <= MAX_COORD;
p.elements().iter().all(|el| match *el {
MoveTo(a) | LineTo(a) => ok(a),
QuadTo(a, b) => ok(a) && ok(b),
CurveTo(a, b, c) => ok(a) && ok(b) && ok(c),
ClosePath => true,
})
}
fn baked(p: &BezPath, xf: Affine) -> BezPath {
let mut out = p.clone();
out.apply_affine(xf);
out
}
fn union(items: &[Item]) -> Option<Rect> {
items.iter().map(|i| i.bbox).reduce(|a, b| a.union(b))
}
fn normal(b: BlendMode) -> bool {
matches!(b, BlendMode::Normal | BlendMode::PassThrough)
}
impl<'a> Walker<'a> {
pub fn new(doc: &'a Document, opts: &'a VdOptions) -> Self {
Self { doc, opts, warn: Warnings::default(), names: Default::default() }
}
/// Reports that the export drew nothing at all (named after `layer`).
pub fn nothing_drawn(&mut self, layer: &str) {
if self.opts.compat != Compat::Ignore {
self.warn.add(Warn::NothingDrawn, layer);
}
}
pub fn finish(self) -> Vec<String> {
self.warn.finish()
}
fn note(&mut self, w: Warn, n: &Node) {
if self.opts.compat != Compat::Ignore {
self.warn.add(w, &n.name);
}
}
/// `name` as an `android:name` not used yet in this file (repeats get `_2`, `_3`…).
fn unique(&mut self, name: &str) -> String {
let base: String = name.chars().take(200).collect();
let base = if base.trim().is_empty() { "layer".to_string() } else { base };
// Bounded: a set of n names leaves one of the first n + 1 candidates free.
let tries = self.names.len().saturating_add(1);
let name = (1..=tries)
.map(|k| if k == 1 { base.clone() } else { format!("{base}_{k}") })
.find(|n| !self.names.contains(n))
.unwrap_or_else(|| format!("{base}_{}", tries.saturating_add(1)));
self.names.insert(name.clone());
name
}
/// What layer `id` paints, drawn through `xf` (its parent's space → viewport).
pub fn node(&mut self, id: NodeId, xf: Affine, depth: usize) -> Vec<Item> {
let doc = self.doc;
let Some(n) = doc.node(id) else { return Vec::new() };
if depth > MAX_EXPORT_DEPTH || !n.visible || n.is_mask {
return Vec::new();
}
let op = n.opacity;
if !(op.is_finite() && op > 0.0) {
return Vec::new();
}
let xf = xf * n.transform;
self.effects(n);
if !normal(n.blend) {
self.note(Warn::Blend, n);
}
let mut items = Vec::new();
if matches!(n.kind, NodeKind::Text(_)) {
self.text(n, xf, &mut items);
} else {
self.shape(n, xf, depth, &mut items);
}
if op < 1.0 {
if Item::overlap(&items) {
match self.opts.compat {
Compat::BestEffort => {
self.note(Warn::OpacityApprox, n);
Item::scale_alpha(&mut items, op);
}
Compat::Exact => {
self.note(Warn::OpacityOmitted, n);
return Vec::new();
}
Compat::Ignore => {}
}
} else {
Item::scale_alpha(&mut items, op);
}
}
if self.opts.include_names && !items.is_empty() {
let container = !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty();
if container {
let bbox = union(&items).unwrap_or(Rect::ZERO);
let name = Some(self.unique(&n.name));
items = vec![Item { kind: ItemKind::Group(GroupItem { name, xf: None, clip: None, children: items }), bbox }];
} else {
for it in &mut items {
let name = Some(self.unique(&n.name));
match &mut it.kind {
ItemKind::Path(p) => p.name = name,
ItemKind::Group(g) => g.name = name,
}
}
}
}
items
}
/// Effects have no Vector Drawable equivalent: each kind is noted.
fn effects(&mut self, n: &Node) {
for e in n.effects.iter().filter(|e| e.visible) {
let w = match e.kind {
EffectKind::DropShadow(_) => Warn::DropShadow,
EffectKind::InnerShadow(_) => Warn::InnerShadow,
EffectKind::LayerBlur(_) => Warn::LayerBlur,
EffectKind::BackgroundBlur(_) => Warn::BackgroundBlur,
EffectKind::Noise(_) => Warn::Noise,
EffectKind::Texture(_) => Warn::Texture,
EffectKind::Glass(_) => Warn::Glass,
};
self.note(w, n);
}
}
fn text(&mut self, n: &Node, xf: Affine, items: &mut Vec<Item>) {
let Some(runs) = text::runs(n) else { return };
for (path, fills) in &runs {
for p in fills {
self.fill(n, p, path, false, xf, items);
}
}
if n.has_visible_stroke() {
// Glyph outlines are closed: inside and outside strokes follow them, as on the canvas.
let even = stroke::outline_even_odd(n, true);
for (path, _) in &runs {
let area = figcraft_render::draw::stroke_area(n, path, true);
for p in &n.strokes {
self.fill(n, p, &area, even, xf, items);
}
}
}
}
fn shape(&mut self, n: &Node, xf: Affine, depth: usize, items: &mut Vec<Item>) {
let doc = self.doc;
let outline = match n.kind {
NodeKind::Group | NodeKind::Page { .. } => None,
_ => figcraft_render::node_outline(doc, n),
};
let boolean = matches!(n.kind, NodeKind::BooleanOp { .. });
let closed = figcraft_doc::geometry::is_closed(n) || boolean;
if closed && let Some((path, rule)) = &outline {
for p in &n.fills {
self.fill(n, p, path, *rule == FillRule::EvenOdd, xf, items);
}
}
if !boolean && !n.children.is_empty() && !n.hides_contents() {
let kids = self.children(n, xf, depth);
let clips = n.clip && n.kind.is_frame_like();
match &outline {
Some((path, rule)) if clips => {
if let Some(g) = self.clipped(n, &baked(path, xf), *rule == FillRule::EvenOdd, kids) {
items.push(g);
}
}
_ => items.extend(kids),
}
}
if n.has_visible_stroke() {
self.strokes(n, outline.as_ref().map(|(p, _)| p), closed, xf, items);
}
}
/// `n`'s children, bottom to top; a visible mask clips the layers above it (up to the next
/// mask) to its outline.
fn children(&mut self, n: &Node, xf: Affine, depth: usize) -> Vec<Item> {
let doc = self.doc;
let mut out = Vec::new();
for run in doc.paint_runs(n) {
let Some(&first) = n.children.get(run.start) else { continue };
let Some(m) = doc.node(first).filter(|m| m.is_mask && m.visible) else {
out.extend(self.node(first, xf, depth + 1));
continue;
};
if m.mask_type != MaskType::Vector {
match self.opts.compat {
Compat::BestEffort => self.note(Warn::MaskApprox, m),
Compat::Exact => {
self.note(Warn::MaskOmitted, m);
continue;
}
Compat::Ignore => {}
}
}
let mut kids = Vec::new();
for k in n.children.get(run.start + 1..run.end).unwrap_or(&[]) {
kids.extend(self.node(*k, xf, depth + 1));
}
// A mask without an outline shows nothing of what it masks.
let Some((outline, rule)) = figcraft_render::subtree_outline(doc, m, depth + 1) else { continue };
if let Some(g) = self.clipped(m, &baked(&outline, xf * m.transform), rule == FillRule::EvenOdd, kids) {
out.push(g);
}
}
out
}
/// `kids` in a group clipped to `clip` (viewport space). A `<clip-path>` has no fill rule:
/// an even-odd outline is rewritten as non-zero first.
fn clipped(&mut self, n: &Node, clip: &BezPath, even: bool, kids: Vec<Item>) -> Option<Item> {
if kids.is_empty() {
return None;
}
if !writable(clip) {
self.note(Warn::TooLarge, n);
return None;
}
let clip = if even && self.opts.compat != Compat::Ignore {
match nonzero(clip) {
Some(p) => {
// Exact either way, but bestEffort reports the rewrite as it does for fills.
if self.opts.compat == Compat::BestEffort {
self.note(Warn::ClipEvenOddRewritten, n);
}
p
}
None if self.opts.compat == Compat::Exact => {
self.note(Warn::ClipEvenOddOmitted, n);
return None;
}
None => {
self.note(Warn::ClipEvenOddApprox, n);
clip.clone()
}
}
} else {
clip.clone()
};
if clip.elements().is_empty() {
return None;
}
let bbox = union(&kids)?.intersect(clip.bounding_box());
Some(Item { kind: ItemKind::Group(GroupItem { name: None, xf: None, clip: Some(clip), children: kids }), bbox })
}
fn strokes(&mut self, n: &Node, outline: Option<&BezPath>, closed: bool, xf: Affine, items: &mut Vec<Item>) {
let native = stroke::native(n, xf);
let mut area: Option<(BezPath, bool)> = None;
for p in &n.strokes {
if !p.visible || p.opacity <= 0.0 {
continue;
}
if let (Some(nat), PaintKind::Solid { color }) = (&native, &p.kind) {
if !normal(p.blend) {
self.note(Warn::Blend, n);
}
let color = color.with_alpha(color.a * p.opacity.clamp(0.0, 1.0));
let data = baked(&nat.path, xf);
if color.a <= 0.0 || data.elements().is_empty() {
continue;
}
if !writable(&data) {
self.note(Warn::TooLarge, n);
continue;
}
// Room for caps and mitred corners around the line.
let reach = nat.width / 2.0 * nat.miter.max(std::f64::consts::SQRT_2);
let bbox = data.bounding_box().inflate(reach, reach);
let stroke = NativeStroke { color, width: nat.width, cap: nat.cap, join: nat.join, miter: nat.miter };
let path = PathItem { name: None, data, fill: None, stroke: Some(stroke), even_odd: false };
items.push(Item { kind: ItemKind::Path(path), bbox });
continue;
}
if area.is_none() {
let geom = match &n.kind {
NodeKind::BooleanOp { .. } => outline.cloned(),
_ => figcraft_doc::geometry::stroke_geometry(n),
};
let Some(g) = geom else { return };
area = Some(((*figcraft_render::draw::stroke_area(n, &g, closed)).clone(), stroke::outline_even_odd(n, closed)));
}
if let Some((a, even)) = &area {
self.fill(n, p, a, *even, xf, items);
}
}
}
/// Paint `p` filling `path` (layer space, even-odd when `even`) of layer `n`.
fn fill(&mut self, n: &Node, p: &Paint, path: &BezPath, even: bool, xf: Affine, items: &mut Vec<Item>) {
if !p.visible || p.opacity <= 0.0 || path.elements().is_empty() {
return;
}
let opacity = p.opacity.clamp(0.0, 1.0);
let fill = match &p.kind {
PaintKind::Solid { color } => {
let c = color.with_alpha(color.a * opacity);
if c.a <= 0.0 {
return;
}
Some(Fill::Solid(c))
}
PaintKind::Gradient(_) => None,
PaintKind::Image(_) => {
self.note(Warn::ImageFill, n);
return;
}
PaintKind::Pattern(_) => {
self.note(Warn::PatternFill, n);
return;
}
};
let mut grad = None;
if let PaintKind::Gradient(g) = &p.kind {
let diamond = g.kind == figcraft_color::GradientKind::Diamond;
if diamond {
match self.opts.compat {
Compat::BestEffort => self.note(Warn::DiamondApprox, n),
Compat::Exact => {
self.note(Warn::DiamondOmitted, n);
return;
}
Compat::Ignore => return,
}
}
grad = Some(gradient(g, opacity, xf, n.width, n.height, diamond));
}
if !normal(p.blend) {
self.note(Warn::Blend, n);
}
let data = baked(path, xf);
if !writable(&data) {
self.note(Warn::TooLarge, n);
return;
}
let (data, even_odd) = if even && self.opts.compat == Compat::BestEffort {
match nonzero(&data) {
Some(nz) => {
self.note(Warn::EvenOddRewritten, n);
(nz, false)
}
None => {
self.note(Warn::EvenOddKept, n);
(data, true)
}
}
} else {
(data, even)
};
if data.elements().is_empty() {
return;
}
let bbox = data.bounding_box();
let path_item = |data: BezPath, fill: Fill| PathItem { name: None, data, fill: Some(fill), stroke: None, even_odd };
match (fill, grad) {
(Some(f), _) => items.push(Item { kind: ItemKind::Path(path_item(data, f)), bbox }),
(None, Some(GradOut::Solid(c))) => items.push(Item { kind: ItemKind::Path(path_item(data, Fill::Solid(c))), bbox }),
(None, Some(GradOut::Plain(g))) => items.push(Item { kind: ItemKind::Path(path_item(data, Fill::Gradient(g))), bbox }),
(None, Some(GradOut::Grouped { xf: gxf, inverse, grad: g })) => {
let inner = Item { kind: ItemKind::Path(path_item(baked(&data, inverse), Fill::Gradient(g))), bbox };
let group: GroupXf = gxf;
items.push(Item { kind: ItemKind::Group(GroupItem { name: None, xf: Some(group), clip: None, children: vec![inner] }), bbox });
}
(None, Some(GradOut::Nothing) | None) => {}
}
}
}
+89
View File
@@ -0,0 +1,89 @@
//! What the export changed or left out: one entry per kind, naming the first few layers.
/// The kinds of change, in the order they're reported.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Warn {
DropShadow,
InnerShadow,
LayerBlur,
BackgroundBlur,
Noise,
Texture,
Glass,
Blend,
ImageFill,
PatternFill,
DiamondApprox,
DiamondOmitted,
OpacityApprox,
OpacityOmitted,
EvenOddRewritten,
EvenOddKept,
ClipEvenOddRewritten,
ClipEvenOddApprox,
ClipEvenOddOmitted,
MaskApprox,
MaskOmitted,
TooLarge,
NothingDrawn,
}
impl Warn {
fn message(self) -> &'static str {
match self {
Warn::DropShadow => "drop shadows were left out",
Warn::InnerShadow => "inner shadows were left out",
Warn::LayerBlur => "layer blurs were left out",
Warn::BackgroundBlur => "background blurs were left out",
Warn::Noise => "noise effects were left out",
Warn::Texture => "texture effects were left out",
Warn::Glass => "glass effects were left out",
Warn::Blend => "blend modes were drawn as normal",
Warn::ImageFill => "image fills were left out",
Warn::PatternFill => "pattern fills were left out",
Warn::DiamondApprox => "diamond gradients were drawn as radial gradients",
Warn::DiamondOmitted => "diamond gradients were left out (exact mode)",
Warn::OpacityApprox => "layer opacity was applied to each shape: where shapes overlap they look denser",
Warn::OpacityOmitted => "layers with opacity over overlapping shapes were left out (exact mode)",
Warn::EvenOddRewritten => "even-odd fills were rewritten as non-zero",
Warn::EvenOddKept => "even-odd fills couldn't be rewritten and use fillType=\"evenOdd\" (API 24+ or AndroidX)",
Warn::ClipEvenOddRewritten => "even-odd clip outlines were rewritten as non-zero",
Warn::ClipEvenOddApprox => "even-odd clip outlines couldn't be rewritten and clip as non-zero",
Warn::ClipEvenOddOmitted => "layers under even-odd clip outlines that couldn't be rewritten were left out (exact mode)",
Warn::MaskApprox => "alpha and luminance masks clip to the mask's outline",
Warn::MaskOmitted => "layers under alpha or luminance masks were left out (exact mode)",
Warn::TooLarge => "shapes with coordinates beyond ±10,000,000 were left out",
Warn::NothingDrawn => "nothing in it could be drawn: the drawable is empty",
}
}
}
/// Layers named per warning.
const NAMED: usize = 3;
#[derive(Default)]
pub(crate) struct Warnings {
seen: std::collections::BTreeMap<Warn, Vec<String>>,
}
impl Warnings {
pub fn add(&mut self, w: Warn, layer: &str) {
let names = self.seen.entry(w).or_default();
if !names.iter().any(|n| n == layer) {
names.push(layer.to_string());
}
}
pub fn finish(self) -> Vec<String> {
self.seen
.into_iter()
.map(|(w, names)| {
let shown: Vec<String> =
names.iter().take(NAMED).map(|n| if n.is_empty() { "unnamed layer".to_string() } else { n.clone() }).collect();
let more = names.len().saturating_sub(NAMED);
let more = if more > 0 { format!(", {more} more") } else { String::new() };
format!("{} ({}{more})", w.message(), shown.join(", "))
})
.collect()
}
}
+243
View File
@@ -0,0 +1,243 @@
//! The 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 figcraft_color::Color;
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::PathEl;
use crate::model::{Fill, GradKind, GroupItem, Item, ItemKind, NativeStroke, PathItem};
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 (the walk leaves out shapes whose numbers aren't finite).
pub(crate) fn num(v: f64) -> String {
if !v.is_finite() {
return "0".into();
}
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(crate) 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(crate) fn color(c: Color) -> String {
let [r, g, b, a] = c.sanitized().to_rgba8();
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(crate) 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));
}
}
fn uses_gradient(items: &[Item]) -> bool {
items.iter().any(|it| match &it.kind {
ItemKind::Path(p) => matches!(p.fill, Some(Fill::Gradient(_))),
ItemKind::Group(g) => uses_gradient(&g.children),
})
}
/// The whole file: a `width`×`height` dp drawable with a viewport of the same size.
pub(crate) 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) {
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: &GroupItem) {
let mut attrs = Vec::new();
if let Some(n) = &g.name {
attrs.push(("android:name", esc(n)));
}
if let Some(x) = &g.xf {
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)));
}
}
if g.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 &g.children {
item(o, depth + 1, c);
}
o.close(depth, "group");
}
fn stroke_attrs(attrs: &mut Vec<(&str, String)>, s: &NativeStroke) {
attrs.push(("android:strokeColor", color(s.color)));
attrs.push(("android:strokeWidth", num(s.width)));
if s.cap != "butt" {
attrs.push(("android:strokeLineCap", s.cap.to_string()));
}
if s.join != "miter" {
attrs.push(("android:strokeLineJoin", s.join.to_string()));
} else if num(s.miter) != "4" {
attrs.push(("android:strokeMiterLimit", num(s.miter)));
}
}
fn path(o: &mut Out, depth: usize, p: &PathItem) {
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 {
GradKind::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)));
}
GradKind::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)));
}
GradKind::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");
}
+1
View File
@@ -18,6 +18,7 @@ const TABLE: &[(&str, u8)] = &[
("format", 3),
("svg", 4),
("pdf", 4),
("vectordrawable", 4),
("engine", 5),
("ui-egui", 6),
("mcp", 6),