From 8b5441eb84d95b538d9355c36c2baba7c1bc5bf0 Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Sun, 18 Oct 2026 19:25:34 +0200 Subject: [PATCH] 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 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. --- Cargo.lock | 14 + Cargo.toml | 1 + crates/vectordrawable/Cargo.toml | 21 + crates/vectordrawable/src/lib.rs | 111 +++++ crates/vectordrawable/src/model.rs | 122 ++++++ crates/vectordrawable/src/paint.rs | 139 +++++++ crates/vectordrawable/src/stroke.rs | 62 +++ crates/vectordrawable/src/tests/compat.rs | 261 ++++++++++++ crates/vectordrawable/src/tests/gradients.rs | 269 ++++++++++++ crates/vectordrawable/src/tests/mod.rs | 99 +++++ crates/vectordrawable/src/tests/shapes.rs | 210 ++++++++++ crates/vectordrawable/src/tests/strokes.rs | 152 +++++++ crates/vectordrawable/src/tests/xml_out.rs | 88 ++++ crates/vectordrawable/src/text.rs | 30 ++ crates/vectordrawable/src/walk.rs | 406 +++++++++++++++++++ crates/vectordrawable/src/warnings.rs | 89 ++++ crates/vectordrawable/src/xml.rs | 243 +++++++++++ xtask/src/layers.rs | 1 + 18 files changed, 2318 insertions(+) create mode 100644 crates/vectordrawable/Cargo.toml create mode 100644 crates/vectordrawable/src/lib.rs create mode 100644 crates/vectordrawable/src/model.rs create mode 100644 crates/vectordrawable/src/paint.rs create mode 100644 crates/vectordrawable/src/stroke.rs create mode 100644 crates/vectordrawable/src/tests/compat.rs create mode 100644 crates/vectordrawable/src/tests/gradients.rs create mode 100644 crates/vectordrawable/src/tests/mod.rs create mode 100644 crates/vectordrawable/src/tests/shapes.rs create mode 100644 crates/vectordrawable/src/tests/strokes.rs create mode 100644 crates/vectordrawable/src/tests/xml_out.rs create mode 100644 crates/vectordrawable/src/text.rs create mode 100644 crates/vectordrawable/src/walk.rs create mode 100644 crates/vectordrawable/src/warnings.rs create mode 100644 crates/vectordrawable/src/xml.rs diff --git a/Cargo.lock b/Cargo.lock index 86bccfa3..523c5f9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 932da46f..9cf82882 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/vectordrawable/Cargo.toml b/crates/vectordrawable/Cargo.toml new file mode 100644 index 00000000..461e9c07 --- /dev/null +++ b/crates/vectordrawable/Cargo.toml @@ -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 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" diff --git a/crates/vectordrawable/src/lib.rs b/crates/vectordrawable/src/lib.rs new file mode 100644 index 00000000..f12e9158 --- /dev/null +++ b/crates/vectordrawable/src/lib.rs @@ -0,0 +1,111 @@ +//! Android Vector Drawable export: the exported layers as one `` XML drawable. +//! +//! Written from Android's public `VectorDrawable` documentation (elements ``, ``, +//! ``, ``, 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 `` can only scale, rotate and translate, a `` 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 +//! `` 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 `` with a ``. +//! +//! 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, + /// 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, +} + +#[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 { + 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; diff --git a/crates/vectordrawable/src/model.rs b/crates/vectordrawable/src/model.rs new file mode 100644 index 00000000..7b6bf7f1 --- /dev/null +++ b/crates/vectordrawable/src/model.rs @@ -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, + pub data: BezPath, + pub fill: Option, + pub stroke: Option, + pub even_odd: bool, +} + +/// A ``'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, + pub xf: Option, + /// A non-zero clip outline in the group's (untransformed) coordinates. + pub clip: Option, + pub children: Vec, +} + +#[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) { + 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 + }) + }) + } +} diff --git a/crates/vectordrawable/src/paint.rs b/crates/vectordrawable/src/paint.rs new file mode 100644 index 00000000..f66d0554 --- /dev/null +++ b/crates/vectordrawable/src/paint.rs @@ -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 +//! `` 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 `` 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 { + 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) +} diff --git a/crates/vectordrawable/src/stroke.rs b/crates/vectordrawable/src/stroke.rs new file mode 100644 index 00000000..df8c00e2 --- /dev/null +++ b/crates/vectordrawable/src/stroke.rs @@ -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 { + 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) +} diff --git a/crates/vectordrawable/src/tests/compat.rs b/crates/vectordrawable/src/tests/compat.rs new file mode 100644 index 00000000..b26047d8 --- /dev/null +++ b/crates/vectordrawable/src/tests/compat.rs @@ -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 { + 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(" (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(" 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 { + 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::().unwrap(), gr.get("scaleY").unwrap().parse::().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::().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:?}"); +} diff --git a/crates/vectordrawable/src/tests/mod.rs b/crates/vectordrawable/src/tests/mod.rs new file mode 100644 index 00000000..d8988e56 --- /dev/null +++ b/crates/vectordrawable/src/tests/mod.rs @@ -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, +} + +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 { + 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 { + 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 `` elements of `xml`. +pub(super) fn paths_of(xml: &str) -> Vec { + elements(xml).into_iter().filter(|e| e.tag == "path").collect() +} diff --git a/crates/vectordrawable/src/tests/shapes.rs b/crates/vectordrawable/src/tests/shapes.rs new file mode 100644 index 00000000..e0f789cc --- /dev/null +++ b/crates/vectordrawable/src/tests/shapes.rs @@ -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 , 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(" = 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(" = 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 }))); +} diff --git a/crates/vectordrawable/src/tests/strokes.rs b/crates/vectordrawable/src/tests/strokes.rs new file mode 100644 index 00000000..b29b2715 --- /dev/null +++ b/crates/vectordrawable/src/tests/strokes.rs @@ -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 = Vec::new(); + let flush = |cmd: char, pts: &mut Vec, 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 +} diff --git a/crates/vectordrawable/src/tests/xml_out.rs b/crates/vectordrawable/src/tests/xml_out.rs new file mode 100644 index 00000000..8976c8d6 --- /dev/null +++ b/crates/vectordrawable/src/tests/xml_out.rs @@ -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\"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 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 = " + + + + + + +"; + 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 without a . + assert!(out.xml.ends_with("android:viewportHeight=\"10\">\n \n\n"), "{}", out.xml); +} diff --git a/crates/vectordrawable/src/text.rs b/crates/vectordrawable/src/text.rs new file mode 100644 index 00000000..acc1f483 --- /dev/null +++ b/crates/vectordrawable/src/text.rs @@ -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)>> { + let NodeKind::Text(_) = &n.kind else { return None }; + let (layout, dy) = figcraft_text::layout_node(n)?; + let mut by_style: std::collections::BTreeMap = 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(), + ) +} diff --git a/crates/vectordrawable/src/walk.rs b/crates/vectordrawable/src/walk.rs new file mode 100644 index 00000000..f085d340 --- /dev/null +++ b/crates/vectordrawable/src/walk.rs @@ -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, +} + +/// 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 { + 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 { + 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 { + 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) { + 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) { + 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 { + 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 `` 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) -> Option { + 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) { + 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) { + 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) => {} + } + } +} diff --git a/crates/vectordrawable/src/warnings.rs b/crates/vectordrawable/src/warnings.rs new file mode 100644 index 00000000..445198d5 --- /dev/null +++ b/crates/vectordrawable/src/warnings.rs @@ -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>, +} + +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 { + self.seen + .into_iter() + .map(|(w, names)| { + let shown: Vec = + 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() + } +} diff --git a/crates/vectordrawable/src/xml.rs b/crates/vectordrawable/src/xml.rs new file mode 100644 index 00000000..48d7dee2 --- /dev/null +++ b/crates/vectordrawable/src/xml.rs @@ -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("&"), + '<' => 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(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 { + /// ``, else `>`. + fn open(&mut self, depth: usize, tag: &str, attrs: &[(&str, String)], close: bool) { + let pad = " ".repeat(depth); + let _ = write!(self.s, "{pad}<{tag}"); + for (i, (k, v)) in attrs.iter().enumerate() { + if i == 0 && attrs.len() == 1 { + let _ = write!(self.s, " {k}=\"{v}\""); + } else { + let _ = write!(self.s, "\n{pad} {k}=\"{v}\""); + } + } + self.s.push_str(if close { " />\n" } else { ">\n" }); + } + + fn close(&mut self, depth: usize, tag: &str) { + let _ = writeln!(self.s, "{}", " ".repeat(depth)); + } +} + +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("\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 without a ("no path defined"): one empty, unfilled path. + o.open(1, "path", &[("android:pathData", "M0,0".to_string())], true); + } + for it in items { + item(&mut o, 1, it); + } + o.close(0, "vector"); + o.s +} + +fn item(o: &mut Out, depth: usize, it: &Item) { + match &it.kind { + ItemKind::Path(p) => path(o, depth, p), + ItemKind::Group(g) => group(o, depth, g), + } +} + +fn group(o: &mut Out, depth: usize, g: &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"); +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 612c31ed..61d60edd 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -18,6 +18,7 @@ const TABLE: &[(&str, u8)] = &[ ("format", 3), ("svg", 4), ("pdf", 4), + ("vectordrawable", 4), ("engine", 5), ("ui-egui", 6), ("mcp", 6),