Merge pull request #840 from StrajnarFilip/feat/vector-drawable-export

M6: export as Android Vector Drawable (XML)
This commit is contained in:
BFlat
2026-10-27 01:06:21 +07:00
committed by GitHub
46 changed files with 2845 additions and 62 deletions
Generated
+15
View File
@@ -1664,6 +1664,7 @@ dependencies = [
"figcraft-render",
"figcraft-svg",
"figcraft-text",
"figcraft-vectordrawable",
"image",
"log",
"regex",
@@ -1864,6 +1865,20 @@ dependencies = [
"web-time",
]
[[package]]
name = "figcraft-vectordrawable"
version = "0.11.2"
dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"roxmltree 0.21.2",
"thiserror 2.0.21",
]
[[package]]
name = "figcraft-web"
version = "0.11.2"
+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" }
+9
View File
@@ -91,6 +91,15 @@ impl Args {
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
}
/// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort).
pub fn vd_compat(&self) -> CliResult<figcraft_doc::VdCompat> {
match self.value("vd-compat") {
None => Ok(figcraft_doc::VdCompat::default()),
Some(s) => figcraft_doc::VdCompat::from_key(&s)
.ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))),
}
}
pub fn scale(&self) -> CliResult<f64> {
match self.value("scale") {
None => Ok(1.0),
+12 -5
View File
@@ -4,7 +4,7 @@ use std::ffi::OsStr;
use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
@@ -25,7 +25,7 @@ fn ext(path: &str) -> String {
fn image_format(e: &str) -> Option<ExportFormat> {
match e {
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e),
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e),
_ => None,
}
}
@@ -225,6 +225,8 @@ pub struct Target {
pub node: Option<String>,
/// PDF only.
pub print: PrintSettings,
/// Android Vector Drawable (XML) only.
pub vd_compat: VdCompat,
}
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
@@ -239,15 +241,20 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
let fmt = match t.format.as_deref() {
Some(f) => {
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig)")))?
}
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
};
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
let single = image_format(&ext(out)).is_some();
let bytes_of = |ids: &[NodeId]| {
let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
let setting = ExportSetting { format: fmt, print: *print, vd_compat: t.vd_compat, ..Default::default() };
let (bytes, warnings) = figcraft_engine::cmd::file::export_file(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))?;
// What an Android Vector Drawable changed or left out, like the .fig notes.
for w in warnings {
eprintln!("note: {w}");
}
Ok::<_, CliError>(bytes)
};
if single {
let bytes = match target {
+11 -5
View File
@@ -17,8 +17,8 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info <file> [--json]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
@@ -144,12 +144,18 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat();
let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
let target =
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
let target = files::Target {
format: a.value("format"),
scale: a.scale()?,
page: a.value("page"),
node: a.value("node"),
print: a.print()?,
vd_compat: a.vd_compat()?,
};
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
+2
View File
@@ -10,3 +10,5 @@ mod fonts;
mod pdf;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf_print;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod vector_drawable;
@@ -0,0 +1,41 @@
//! `figcraft-cli export … -o out.xml` (Android Vector Drawable) end to end.
use std::process::Command;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
}
#[test]
fn xml_export_writes_a_drawable_and_prints_its_notes() {
let d = std::env::temp_dir().join(format!("figcraft-cli-vd-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join("s.jsonl");
std::fs::write(
&script,
r##"{"command":"shape.rectangle","params":{"x":0,"y":0,"width":24,"height":24,"name":"Dot","props":{"fill":"#ff0000","effects":[{"type":"dropShadow"}]}}}"##,
)
.unwrap();
let file = d.join("doc.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let xml = d.join("icon.xml");
let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let text = std::fs::read_to_string(&xml).unwrap();
assert!(text.contains("<vector") && text.contains("android:fillColor=\"#FF0000\""), "{text}");
assert!(String::from_utf8_lossy(&out.stderr).contains("note: drop shadows were left out (Dot)"));
// --format xml into a folder; --vd-compat ignore prints nothing.
let dir = d.join("out");
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--format", "xml", "--vd-compat", "ignore"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(dir.join("Dot.xml").exists());
assert!(!String::from_utf8_lossy(&out.stderr).contains("note:"));
let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).args(["--vd-compat", "sloppy"]).output().unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("--vd-compat"));
let _ = std::fs::remove_dir_all(&d);
}
+51 -1
View File
@@ -1233,11 +1233,14 @@ pub enum ExportFormat {
Svg,
Pdf,
Webp,
/// Android Vector Drawable (an `.xml` drawable resource).
VectorDrawable,
}
impl ExportFormat {
/// Every format, in the order the export menus list them.
pub const ALL: [ExportFormat; 5] = [ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Webp];
pub const ALL: [ExportFormat; 6] =
[ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Webp, ExportFormat::VectorDrawable];
/// Pixel formats (their size depends on the export scale).
pub fn is_raster(self) -> bool {
@@ -1252,9 +1255,15 @@ impl ExportFormat {
ExportFormat::Svg => "SVG",
ExportFormat::Pdf => "PDF",
ExportFormat::Webp => "WebP",
ExportFormat::VectorDrawable => "XML",
}
}
/// Formats whose output is vector (one file whatever the scale).
pub fn is_vector(self) -> bool {
matches!(self, ExportFormat::Svg | ExportFormat::Pdf | ExportFormat::VectorDrawable)
}
pub fn ext(self) -> &'static str {
match self {
ExportFormat::Png => "png",
@@ -1262,6 +1271,7 @@ impl ExportFormat {
ExportFormat::Svg => "svg",
ExportFormat::Pdf => "pdf",
ExportFormat::Webp => "webp",
ExportFormat::VectorDrawable => "xml",
}
}
pub fn from_ext(s: &str) -> Option<Self> {
@@ -1271,6 +1281,7 @@ impl ExportFormat {
"svg" => Some(ExportFormat::Svg),
"pdf" => Some(ExportFormat::Pdf),
"webp" => Some(ExportFormat::Webp),
"xml" | "vd" | "vectordrawable" => Some(ExportFormat::VectorDrawable),
_ => None,
}
}
@@ -1439,6 +1450,44 @@ pub struct ExportSetting {
/// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines.
#[serde(skip_serializing_if = "is_true")]
pub svg_simplify_stroke: bool,
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
#[serde(skip_serializing_if = "VdCompat::is_default")]
pub vd_compat: VdCompat,
}
/// How an Android Vector Drawable export handles what the format can't draw exactly (layer
/// opacity over overlapping shapes, even-odd fills, diamond gradients, alpha masks).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VdCompat {
/// The closest drawing, with a warning for each approximation.
#[default]
BestEffort,
/// Never approximate: what can't be drawn exactly is left out, with a warning.
Exact,
/// Write what maps, leave out the rest, no warnings.
Ignore,
}
impl VdCompat {
pub const ALL: [VdCompat; 3] = [VdCompat::BestEffort, VdCompat::Exact, VdCompat::Ignore];
fn is_default(&self) -> bool {
*self == VdCompat::BestEffort
}
/// The name commands, files and the CLI use ("bestEffort").
pub fn key(self) -> &'static str {
match self {
VdCompat::BestEffort => "bestEffort",
VdCompat::Exact => "exact",
VdCompat::Ignore => "ignore",
}
}
pub fn from_key(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.key() == s)
}
}
impl Default for ExportSetting {
@@ -1453,6 +1502,7 @@ impl Default for ExportSetting {
svg_outline_text: true,
svg_id_attribute: false,
svg_simplify_stroke: true,
vd_compat: VdCompat::BestEffort,
}
}
}
+1
View File
@@ -20,6 +20,7 @@ figcraft-render = { workspace = true }
figcraft-format = { workspace = true }
figcraft-svg = { workspace = true }
figcraft-pdf = { workspace = true }
figcraft-vectordrawable = { workspace = true }
craft-docpath = { workspace = true }
figcraft-plugin = { workspace = true }
serde = { workspace = true }
+2 -2
View File
@@ -317,7 +317,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"export.add",
"Add export setting",
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp, bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
true,
|s, p| {
let cmd = "export.add";
@@ -335,7 +335,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"export.update",
"Edit export setting",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}",
true,
|s, p| {
let cmd = "export.update";
+59 -16
View File
@@ -77,14 +77,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.export",
"Export",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
false,
export
),
spec!(
"file.exportLayers",
"Export layers",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}",
false,
export_layers
),
@@ -1271,14 +1271,20 @@ fn slice_container(doc: &Document, slice: NodeId) -> Option<NodeId> {
}
/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see
/// [`export_scale`]): contents only, include bounding box, the SVG options and PDF print settings.
/// [`export_scale`]): contents only, include bounding box, the SVG, PDF and Vector Drawable options.
pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<Vec<u8>> {
export_file(doc, ids, setting, scale).map(|(bytes, _)| bytes)
}
/// [`export_bytes_with`] and what the export changed or left out (Android Vector Drawable only;
/// empty for the other formats).
pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<(Vec<u8>, Vec<String>)> {
let nothing = || EngineError::Disabled("file.export".into(), "nothing to export".into());
if ids.is_empty() {
return Err(nothing());
}
let drawn = drawn_layers(doc, ids, setting);
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) {
if setting.format.is_vector() {
figcraft_render::check_export_depth(doc, &drawn).map_err(|e| EngineError::Other(e.to_string()))?;
}
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
@@ -1295,22 +1301,37 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting
simplify_stroke: setting.svg_simplify_stroke,
bounds: Some(area),
};
figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).map_err(|e| match e {
let svg = figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map_err(|e| match e {
figcraft_svg::SvgError::Empty => nothing(),
other => EngineError::Other(other.to_string()),
})
})?;
Ok((svg.into_bytes(), Vec::new()))
}
// Android colours are sRGB too.
ExportFormat::VectorDrawable => {
let compat = match setting.vd_compat {
VdCompat::BestEffort => figcraft_vectordrawable::Compat::BestEffort,
VdCompat::Exact => figcraft_vectordrawable::Compat::Exact,
VdCompat::Ignore => figcraft_vectordrawable::Compat::Ignore,
};
let opts = figcraft_vectordrawable::VdOptions { bounds: Some(area), include_names: setting.svg_id_attribute, compat };
let out = figcraft_vectordrawable::try_export(&doc.in_srgb(), &drawn, &opts).map_err(|e| match e {
figcraft_vectordrawable::VdError::Empty => nothing(),
other => EngineError::Other(other.to_string()),
})?;
Ok((out.xml.into_bytes(), out.warnings))
}
ExportFormat::Pdf => {
let doc = &*doc.in_srgb();
// Frames export one page each; anything else shares one page.
let all_frames = own_area && ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
let groups: Vec<Vec<NodeId>> = if all_frames { ids.iter().map(|i| vec![*i]).collect() } else { vec![drawn] };
pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))
Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new()))
}
_ => {
let pix = figcraft_render::render_area(doc, &drawn, area, scale)
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)
Ok((figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)?, Vec::new()))
}
}
}
@@ -1347,14 +1368,19 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE),
};
let format = setting.format;
let bytes = export_bytes_with(doc, &ids, &setting, scale)?;
match string(p, "path") {
let (bytes, warnings) = export_file(doc, &ids, &setting, scale)?;
let mut out = match string(p, "path") {
Some(path) => {
write(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format.ext()}))
json!({"path": path, "bytes": bytes.len(), "format": format.ext()})
}
None => Ok(json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
None => json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}),
};
// Android Vector Drawables say what they changed or left out (an empty list when nothing).
if format == ExportFormat::VectorDrawable {
out["warnings"] = json!(warnings);
}
Ok(out)
}
/// The whole document as a `.fig` file (experimental: only round trips through our own importer
@@ -1380,7 +1406,7 @@ pub fn fig_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 {
// Size-based exports ask for bounds before encoding. Let encoding report the depth error
// without entering a recursive bounds walk here; SVG/PDF do not use this raster scale.
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) && figcraft_render::check_export_depth(doc, ids).is_err() {
if setting.format.is_vector() && figcraft_render::check_export_depth(doc, ids).is_err() {
return 1.0;
}
export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height()))
@@ -1395,7 +1421,10 @@ pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Resu
e.suffix = s.chars().filter(|c| !c.is_control()).take(200).collect();
}
if let Some(f) = string(p, "format") {
e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp"))?;
e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp|xml"))?;
}
if let Some(c) = vd_compat_param(cmd, p)? {
e.vd_compat = c;
}
e.print = print_param(cmd, p, e.print)?;
for (key, slot) in [
@@ -1413,6 +1442,14 @@ pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Resu
Ok(e)
}
/// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error).
fn vd_compat_param(cmd: &str, p: &Value) -> Result<Option<VdCompat>> {
match get(p, "vdCompat") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(VdCompat::from_key).map(Some).ok_or_else(|| bad(cmd, "vdCompat: bestEffort|exact|ignore")),
}
}
/// Whether `p` sets any PDF print option.
fn has_print_param(p: &Value) -> bool {
["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null()))
@@ -1548,12 +1585,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
let mut members = Vec::new();
// Print options passed here stand in for every PDF setting's own.
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
// And a compatibility mode for every Android Vector Drawable.
let compat = vd_compat_param(cmd, p)?;
let mut files = Vec::with_capacity(jobs.len());
for job in &jobs {
let setting = ExportSetting { print: print.unwrap_or(job.setting.print), ..job.setting.clone() };
let setting =
ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() };
let scale = export_scale(doc, &[job.id], &setting);
let bytes = export_bytes_with(doc, &[job.id], &setting, scale)?;
let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?;
let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()});
if setting.format == ExportFormat::VectorDrawable {
f["warnings"] = json!(warnings);
}
match &dir {
Some(d) => {
let path = std::path::Path::new(d).join(&job.file);
+2
View File
@@ -1997,6 +1997,8 @@ mod variants_discovery_tests;
#[cfg(test)]
mod vector_behaviour_tests;
#[cfg(test)]
mod vector_drawable_export_tests;
#[cfg(test)]
mod vector_fill_rule_tests;
#[cfg(test)]
mod vector_multi_behaviour_tests;
@@ -0,0 +1,108 @@
//! Android Vector Drawable (XML) export through the commands: one-off exports with their
//! warnings, export settings with a compatibility mode (stored, saved, edited), export layers,
//! and bad values refused.
use base64::Engine as _;
use figcraft_doc::*;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
/// A session with a 48 × 24 frame holding two overlapping rectangles at 50% opacity.
fn session() -> (Session, NodeId) {
let mut s = Session::new();
let r = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 48, "height": 24, "name": "Badge"})).unwrap();
let f = id_of(&r["id"]).unwrap();
for x in [0, 12] {
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 24, "height": 24, "parent": f})).unwrap();
}
s.execute("node.set", &json!({"ids": [f], "props": {"opacity": 0.5}})).unwrap();
s.set_selection(vec![f]);
(s, f)
}
fn xml(r: &Value) -> String {
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
String::from_utf8(bytes).unwrap()
}
#[test]
fn a_one_off_export_returns_the_drawable_and_its_warnings() {
let (mut s, f) = session();
let r = s.execute("file.export", &json!({"ids": [f], "format": "xml"})).unwrap();
assert_eq!(r["format"], "xml");
let x = xml(&r);
assert!(x.starts_with("<?xml") && x.contains("<vector") && x.contains("android:width=\"48dp\""), "{x}");
assert_eq!(r["warnings"], json!(["layer opacity was applied to each shape: where shapes overlap they look denser (Badge)"]));
// Exact leaves the faded frame out; ignore draws it opaque, without warnings.
let r = s.execute("file.export", &json!({"ids": [f], "format": "xml", "vdCompat": "exact"})).unwrap();
assert!(!xml(&r).contains("fillColor"), "{}", xml(&r));
// Still a valid (empty) drawable, and it says so.
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(2));
assert_eq!(r["warnings"][1], "nothing in it could be drawn: the drawable is empty (Badge)");
let r = s.execute("file.export", &json!({"ids": [f], "format": "xml", "vdCompat": "ignore"})).unwrap();
assert_eq!(r["warnings"], json!([]));
// Names only when asked.
assert!(!xml(&r).contains("android:name"));
let r = s.execute("file.export", &json!({"ids": [f], "format": "xml", "svgIdAttribute": true})).unwrap();
assert!(xml(&r).contains("android:name=\"Badge\""), "{}", xml(&r));
// Other formats don't grow a `warnings` field.
assert!(s.execute("file.export", &json!({"ids": [f], "format": "svg"})).unwrap().get("warnings").is_none());
}
#[test]
fn a_path_ending_in_xml_picks_the_format() {
let (mut s, f) = session();
let dir = std::env::temp_dir().join(format!("figcraft-vd-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("badge.xml");
let r = s.execute("file.export", &json!({"ids": [f], "path": path.to_string_lossy()})).unwrap();
assert_eq!(r["format"], "xml");
assert!(std::fs::read_to_string(&path).unwrap().contains("<vector"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_settings_keep_their_compatibility_mode() {
let (mut s, f) = session();
s.execute("export.add", &json!({"ids": [f], "setting": {"format": "xml", "vdCompat": "exact"}})).unwrap();
let e = s.doc().unwrap().node(f).unwrap().exports[0].clone();
assert_eq!((e.format, e.vd_compat), (ExportFormat::VectorDrawable, VdCompat::Exact));
// Stored camelCase, the default left out.
let v = serde_json::to_value(&e).unwrap();
assert_eq!((v["format"].as_str(), v["vdCompat"].as_str()), (Some("vectorDrawable"), Some("exact")));
assert!(serde_json::to_value(ExportSetting::default()).unwrap().get("vdCompat").is_none());
// Saved and loaded.
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
assert_eq!(back.node(f).unwrap().exports[0], e);
// Edited and undone.
s.execute("export.update", &json!({"ids": [f], "index": 0, "vdCompat": "ignore"})).unwrap();
assert_eq!(s.doc().unwrap().node(f).unwrap().exports[0].vd_compat, VdCompat::Ignore);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().node(f).unwrap().exports[0].vd_compat, VdCompat::Exact);
// Export layers: one .xml file with its warnings; a vdCompat passed there wins.
let r = s.execute("file.exportLayers", &json!({"ids": [f]})).unwrap();
let file = &r["files"][0];
assert_eq!(file["name"], "Badge.xml");
assert_eq!(file["warnings"].as_array().map(Vec::len), Some(2), "exact: left out, so empty");
let r = s.execute("file.exportLayers", &json!({"ids": [f], "vdCompat": "ignore"})).unwrap();
assert_eq!(r["files"][0]["warnings"], json!([]));
}
#[test]
fn bad_values_are_refused_and_change_nothing() {
let (mut s, f) = session();
s.execute("export.add", &json!({"ids": [f], "setting": {"format": "xml"}})).unwrap();
for junk in [json!("loose"), json!(3), json!(true), json!("BESTEFFORT"), json!({})] {
assert!(s.execute("file.export", &json!({"ids": [f], "format": "xml", "vdCompat": junk})).is_err(), "{junk}");
assert!(s.execute("export.update", &json!({"ids": [f], "index": 0, "vdCompat": junk})).is_err(), "{junk}");
assert!(s.execute("export.add", &json!({"ids": [f], "setting": {"vdCompat": junk}})).is_err(), "{junk}");
assert!(s.execute("file.exportLayers", &json!({"ids": [f], "vdCompat": junk})).is_err(), "{junk}");
}
let n = s.doc().unwrap().node(f).unwrap();
assert_eq!((n.exports.len(), n.exports[0].vd_compat), (1, VdCompat::BestEffort));
let err = s.execute("file.export", &json!({"ids": [f], "format": "vml"})).unwrap_err().to_string();
assert!(err.contains("xml"), "{err}");
}
+5 -1
View File
@@ -1206,6 +1206,9 @@ impl<'d> Exporter<'d> {
if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::Webp) {
self.warn("WebP export settings were written as PNG");
}
if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::VectorDrawable) {
self.warn("Android Vector Drawable (XML) export settings were written as SVG");
}
f.push(("exportSettings", arr(n.exports.iter().map(export_setting).collect())));
}
self.prototype(n, f);
@@ -2824,7 +2827,8 @@ fn export_setting(x: &figcraft_doc::ExportSetting) -> Kv<'static> {
// The format has no WebP export setting.
figcraft_doc::ExportFormat::Png | figcraft_doc::ExportFormat::Webp => "PNG",
figcraft_doc::ExportFormat::Jpg => "JPEG",
figcraft_doc::ExportFormat::Svg => "SVG",
// Nor an Android Vector Drawable one: the nearest is the other vector format.
figcraft_doc::ExportFormat::Svg | figcraft_doc::ExportFormat::VectorDrawable => "SVG",
figcraft_doc::ExportFormat::Pdf => "PDF",
};
let (ty, value) = match x.size {
+13
View File
@@ -1188,6 +1188,19 @@ fn local_samples_round_trip() {
}
}
#[test]
fn a_vector_drawable_export_setting_is_written_as_svg_with_a_warning() {
let mut doc = Document::new("vd");
let page = doc.first_page().unwrap();
let r = doc.create(page, NodeKind::Rectangle, "Icon", 0.0, 0.0, 24.0, 24.0).unwrap();
doc.get_mut(r).unwrap().exports = vec![ExportSetting { format: ExportFormat::VectorDrawable, ..Default::default() }];
let (bytes, warnings) = export(&doc, None).unwrap();
assert_eq!(warnings, ["Android Vector Drawable (XML) export settings were written as SVG"]);
let (back, _) = import(&bytes, "x").unwrap();
let icon = back.nodes().find(|n| n.name == "Icon").unwrap();
assert_eq!(icon.exports.iter().map(|x| x.format).collect::<Vec<_>>(), [ExportFormat::Svg]);
}
#[test]
fn images_are_named_by_the_sha1_of_their_bytes() {
// FIPS 180 test vectors.
+23 -7
View File
@@ -213,11 +213,11 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"export",
"Export",
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp or pdf at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, other formats as base64 `data`.",
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.",
obj(
json!({
"ids": ids_schema("Layers to export (several are composited into one image)"),
"format": {"type": "string", "enum": ["png", "jpg", "svg", "webp", "pdf", "fig"], "description": "Format (default png, or from the path's extension)"},
"format": {"type": "string", "enum": ["png", "jpg", "svg", "webp", "pdf", "xml", "fig"], "description": "Format (default png, or from the path's extension)"},
"scale": num("Pixel scale for raster formats (default 1, max 64)"),
"size": string("Instead of `scale`: `2x`, or a width or height in pixels like `512w` / `512h`"),
"path": string("Output file path"),
@@ -230,6 +230,7 @@ pub fn tool_definitions() -> Vec<Value> {
"svgOutlineText": boolean("SVG: text as outlines (default true); false keeps `<text>` elements"),
"svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"),
"svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"),
"vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"},
}),
&[],
),
@@ -879,8 +880,8 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
}
let format = s_arg(a, "format")?.map(str::to_ascii_lowercase);
if let Some(f) = &format {
if !["png", "jpg", "jpeg", "svg", "webp", "pdf", "fig"].contains(&f.as_str()) {
return Err(format!("unknown format `{f}` (png|jpg|svg|webp|pdf|fig)"));
if !["png", "jpg", "jpeg", "svg", "webp", "pdf", "xml", "fig"].contains(&f.as_str()) {
return Err(format!("unknown format `{f}` (png|jpg|svg|webp|pdf|xml|fig)"));
}
params.insert("format".into(), json!(f));
}
@@ -894,9 +895,18 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
params.insert("size".into(), json!(s));
}
// PDF print options and the export options, checked by the command.
for k in
["bleed", "cropMarks", "bleedMarks", "cmyk", "contentsOnly", "includeBoundingBox", "svgOutlineText", "svgIdAttribute", "svgSimplifyStroke"]
{
for k in [
"bleed",
"cropMarks",
"bleedMarks",
"cmyk",
"contentsOnly",
"includeBoundingBox",
"svgOutlineText",
"svgIdAttribute",
"svgSimplifyStroke",
"vdCompat",
] {
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
params.insert(k.into(), v.clone());
}
@@ -916,6 +926,12 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
"jpg" | "jpeg" => Ok(ToolResult::image(&bytes, "image/jpeg", &json!({"format": "jpg", "bytes": bytes.len()}))),
"webp" => Ok(ToolResult::image(&bytes, "image/webp", &json!({"format": "webp", "bytes": bytes.len()}))),
"svg" => Ok(ToolResult::text(String::from_utf8_lossy(&bytes).into_owned())),
"xml" => {
let info = json!({"format": "xml", "bytes": bytes.len(), "warnings": r.get("warnings").cloned().unwrap_or_else(|| json!([]))});
let mut out = ToolResult::text(String::from_utf8_lossy(&bytes).into_owned());
out.content.extend(ToolResult::json(&info).content);
Ok(out)
}
other => {
let mut out = json!({"format": other, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)});
if let Some(w) = r.get("warnings") {
+27
View File
@@ -0,0 +1,27 @@
//! The `export` tool's Android Vector Drawable format: the XML as text, then its warnings.
use crate::common::Client;
use serde_json::{Value, json};
#[test]
fn xml_comes_back_as_text_and_its_warnings() {
let mut c = Client::new();
let id = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 24, "height": 16}));
let r = c.call("export", json!({"ids": [id], "format": "xml"}));
assert_eq!(r["isError"], false, "{r}");
let blocks = r["content"].as_array().unwrap();
let xml = blocks[0]["text"].as_str().unwrap();
assert!(xml.starts_with("<?xml") && xml.contains("android:width=\"24dp\"") && xml.contains("android:height=\"16dp\""), "{xml}");
let info: Value = serde_json::from_str(blocks[1]["text"].as_str().unwrap()).unwrap();
assert_eq!((info["format"].as_str(), info["warnings"].as_array().map(Vec::len)), (Some("xml"), Some(0)));
c.ok("set_properties", json!({"ids": [id], "props": {"effects": [{"type": "dropShadow"}]}}));
let r = c.call("export", json!({"ids": [id], "format": "xml"}));
let info: Value = serde_json::from_str(r["content"][1]["text"].as_str().unwrap()).unwrap();
assert_eq!(info["warnings"], json!(["drop shadows were left out (Rectangle)"]), "{info}");
let r = c.call("export", json!({"ids": [id], "format": "xml", "vdCompat": "ignore"}));
let info: Value = serde_json::from_str(r["content"][1]["text"].as_str().unwrap()).unwrap();
assert_eq!(info["warnings"], json!([]));
assert!(c.err("export", json!({"ids": [id], "format": "xml", "vdCompat": "loose"})).contains("vdCompat"));
}
+2
View File
@@ -11,6 +11,8 @@ mod common;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_hidden_roots;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_xml;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod garbage;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod inspect_response_limit;
+2 -2
View File
@@ -426,7 +426,7 @@ pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
/// Encode an export in `format` (PNG, JPEG over white, WebP) tagged with the document's colour
/// `profile`. An sRGB PNG gets an `sRGB` chunk; a Display P3 PNG gets the profile as `iCCP` plus
/// `cICP` code points; Display P3 JPEG and WebP files embed the ICC profile (untagged files are read
/// as sRGB). SVG and PDF are not pixel formats: an error.
/// as sRGB). SVG, PDF and Vector Drawable are not pixel formats: an error.
pub fn encode_export(p: &Pixmap, format: ExportFormat, profile: ColorProfile) -> Result<Vec<u8>, String> {
let icc = (!profile.is_srgb()).then(|| profile.icc());
match format {
@@ -436,7 +436,7 @@ pub fn encode_export(p: &Pixmap, format: ExportFormat, profile: ColorProfile) ->
}
ExportFormat::Jpg => jpeg_with(p, Color::WHITE, 90, icc),
ExportFormat::Webp => webp_with(p, icc),
ExportFormat::Svg | ExportFormat::Pdf => Err(format!("{} is not a pixel format", format.label())),
ExportFormat::Svg | ExportFormat::Pdf | ExportFormat::VectorDrawable => Err(format!("{} is not a pixel format", format.label())),
}
}
+7
View File
@@ -109,6 +109,7 @@ name = "English"
"An invite link that opens FigCraft and joins" = "An invite link that opens FigCraft and joins"
"Anchor" = "Anchor"
"Android" = "Android"
"Android Vector Drawable" = "Android Vector Drawable"
"Angular" = "Angular"
"Animation" = "Animation"
"Annotations" = "Annotations"
@@ -166,6 +167,7 @@ name = "English"
"Basics" = "Basics"
"Before" = "Before"
"Bend" = "Bend"
"Best effort" = "Best effort"
"Between the selected layers" = "Between the selected layers"
"Bevel" = "Bevel"
"Binary assets added" = "Binary assets added"
@@ -224,6 +226,7 @@ name = "English"
"Code or invite link" = "Code or invite link"
"Color profile: {name}" = "Color profile: {name}"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards"
"Compatibility" = "Compatibility"
"Connector" = "Connector"
"Convert to frame" = "Convert to frame"
"Convert to section" = "Convert to section"
@@ -491,6 +494,7 @@ name = "English"
"Essential" = "Essential"
"Every conflict has a choice." = "Every conflict has a choice."
"Every top-level frame already starts a flow" = "Every top-level frame already starts a flow"
"Exact only" = "Exact only"
"Exact opacity (4 then 5: 45%)" = "Exact opacity (4 then 5: 45%)"
"Exclude" = "Exclude"
"Excluded" = "Excluded"
@@ -509,8 +513,10 @@ name = "English"
"Export {n} layers" = { one = "Export {n} layer", other = "Export {n} layers" }
"Exported {file}" = "Exported {file}"
"Exported {name}" = "Exported {name}"
"Exported {name}; {n} kinds of content were changed or left out" = { one = "Exported {name}; {n} kind of content was changed or left out", other = "Exported {name}; {n} kinds of content were changed or left out" }
"Exported {name}; {n} kinds of content were left out" = { one = "Exported {name}; {n} kind of content was left out", other = "Exported {name}; {n} kinds of content were left out" }
"Exported {n} files" = { one = "Exported {n} file", other = "Exported {n} files" }
"Exported {n} files; some content was changed or left out" = { one = "Exported {n} file; some content was changed or left out", other = "Exported {n} files; some content was changed or left out" }
"Export…" = "Export…"
"Expose properties to instances" = "Expose properties to instances"
"Exposure" = "Exposure"
@@ -654,6 +660,7 @@ name = "English"
"If {condition}" = "If {condition}"
"Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)"
"Ignore overlapping layers" = "Ignore overlapping layers"
"Ignore unsupported" = "Ignore unsupported"
"Image" = "Image"
"Images" = "Images"
"Import" = "Import"
+8 -1
View File
@@ -8,22 +8,25 @@ name = "Español"
status = "draft — needs native review"
[ui]
"Add a minute" = "Añadir un minuto"
"Add column" = "Añadir columna"
"Add measurement" = "Añadir medición"
"Add row" = "Añadir fila"
"Add a minute" = "Añadir un minuto"
"All objects" = "Todos los objetos"
"Along the bottom" = "A lo largo del borde inferior"
"Along the left" = "A lo largo del borde izquierdo"
"Along the right" = "A lo largo del borde derecho"
"Along the top" = "A lo largo del borde superior"
"Android Vector Drawable" = "Android Vector Drawable"
"Arrow at the end" = "Flecha al final"
"Best effort" = "Mejor aproximación"
"Between the selected layers" = "Entre las capas seleccionadas"
"Brainstorm" = "Lluvia de ideas"
"Check" = "Visto"
"Chevron" = "Cheurón"
"Clear results" = "Borrar resultados"
"Click objects to vote; click your badge to take a vote back" = "Haz clic en los objetos para votar; haz clic en tu insignia para retirar un voto"
"Compatibility" = "Compatibilidad"
"Compatibility notes" = "Notas de compatibilidad"
"Connector" = "Conector"
"Couldn't download the variables" = "No se pudieron descargar las variables"
@@ -490,6 +493,7 @@ status = "draft — needs native review"
"Essential" = "Esenciales"
"Every conflict has a choice." = "Todos los conflictos tienen una elección."
"Every top-level frame already starts a flow" = "Todos los marcos de nivel superior ya inician un flujo"
"Exact only" = "Solo exacto"
"Exact opacity (4 then 5: 45%)" = "Opacidad exacta (4 y luego 5: 45 %)"
"Exclude" = "Excluir"
"Excluded" = "Excluidos"
@@ -508,8 +512,10 @@ status = "draft — needs native review"
"Export {n} layers" = { one = "Exportar {n} capa", other = "Exportar {n} capas" }
"Exported {file}" = "Exportado: {file}"
"Exported {name}" = "{name} exportado"
"Exported {name}; {n} kinds of content were changed or left out" = { one = "{name} exportado; se cambió u omitió {n} tipo de contenido", other = "{name} exportado; se cambiaron u omitieron {n} tipos de contenido" }
"Exported {name}; {n} kinds of content were left out" = { one = "{name} exportado; se omitió {n} tipo de contenido", other = "{name} exportado; se omitieron {n} tipos de contenido" }
"Exported {n} files" = { one = "{n} archivo exportado", other = "{n} archivos exportados" }
"Exported {n} files; some content was changed or left out" = { one = "{n} archivo exportado; se cambió u omitió parte del contenido", other = "{n} archivos exportados; se cambió u omitió parte del contenido" }
"Export…" = "Exportar…"
"Expose properties to instances" = "Exponer propiedades a las instancias"
"Exposure" = "Exposición"
@@ -652,6 +658,7 @@ status = "draft — needs native review"
"If {condition}" = "Si {condition}"
"Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)"
"Ignore overlapping layers" = "Ignorar capas superpuestas"
"Ignore unsupported" = "Ignorar lo no admitido"
"Image" = "Imagen"
"Images" = "Imágenes"
"Import" = "Importar"
+8 -3
View File
@@ -770,6 +770,7 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) {
preset(2.0, ExportFormat::Webp),
preset(1.0, ExportFormat::Svg),
preset(1.0, ExportFormat::Pdf),
preset(1.0, ExportFormat::VectorDrawable),
];
let mut choice = None;
window(tr("Export"), ctx).open(&mut open).show(ctx, |ui| {
@@ -783,14 +784,18 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) {
ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border));
ui.add_space(10.0);
};
let row = |ui: &mut egui::Ui, label: &str| {
let row_tip = |ui: &mut egui::Ui, label: &str, tip: Option<&str>| {
let (r, resp) = ui.allocate_exact_size(vec2(320.0, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
ui.painter().text(r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, label, regular(12.0), t.text);
resp.clicked()
match tip {
Some(tip) => resp.on_hover_text(tip).clicked(),
None => resp.clicked(),
}
};
let row = |ui: &mut egui::Ui, label: &str| row_tip(ui, label, None);
if !jobs.is_empty() {
caption(ui, tr("With the layers' export settings"));
const SHOWN: usize = 6;
@@ -812,7 +817,7 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) {
ui.add_space(4.0);
for p in &presets {
let label = if p.format.is_raster() { format!("{} @{}", p.format.label(), p.size.label()) } else { p.format.label().to_string() };
if row(ui, &label) {
if row_tip(ui, &label, crate::export_panel::format_tip(p.format)) {
choice = Some(ExportChoice::Once(p.clone()));
}
}
+55 -11
View File
@@ -11,9 +11,9 @@ use figcraft_engine::cmd::file::export_jobs;
use serde_json::{Value, json};
use crate::FigApp;
use crate::i18n::{tr, trf, trn};
use crate::i18n::{tr, tr_noop, trf, trn};
use crate::icons::{self, Icon};
use crate::right::{common, menu_item, menu_popup, row};
use crate::right::{common, menu_popup, menu_row, row};
use crate::theme::{Tokens, regular};
use crate::widgets::{self, FIELD_H};
@@ -85,10 +85,18 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo
{
app.run("export.update", json!({"ids": ids, "index": i, "suffix": sfx}));
}
let resp = widgets::dropdown(ui, e.format.label(), format_w, None);
let mut resp = widgets::dropdown(ui, e.format.label(), format_w, None);
if let Some(tip) = format_tip(e.format) {
resp = resp.on_hover_text(tip);
}
menu_popup(&resp).show(|ui| {
for f in ExportFormat::ALL {
if menu_item(ui, f.label(), "") {
let item = menu_row(ui, f.label(), "", 0.0, |_, _, _| {});
let item = match format_tip(f) {
Some(tip) => item.on_hover_text(tip),
None => item,
};
if item.clicked() {
app.run("export.update", json!({"ids": ids, "index": i, "format": f.ext()}));
ui.close();
}
@@ -115,10 +123,24 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo
}
}
/// What a format's short label doesn't say ("XML" is an Android Vector Drawable), for its tooltip.
pub(crate) fn format_tip(f: ExportFormat) -> Option<&'static str> {
match f {
ExportFormat::VectorDrawable => Some(tr("Android Vector Drawable")),
_ => None,
}
}
/// The Android Vector Drawable compatibility modes, as the advanced options list them.
/// Best effort approximates what the format can't draw (with a warning), exact only leaves it out
/// (with a warning), ignore leaves it out silently.
const VD_COMPAT: [(VdCompat, &str); 3] =
[(VdCompat::BestEffort, tr_noop("Best effort")), (VdCompat::Exact, tr_noop("Exact only")), (VdCompat::Ignore, tr_noop("Ignore unsupported"))];
/// A setting's advanced options: ignore overlapping layers (`contentsOnly`), include bounding box
/// for text layers (and for any setting that has it on, so it can be turned off), and for SVG
/// outline text, the id attribute and simplified strokes. Returns the changed option as command
/// params.
/// for text layers (and for any setting that has it on, so it can be turned off); for SVG outline
/// text, the id attribute and simplified strokes; for XML (Android Vector Drawable) the id attribute
/// (as `android:name`) and the compatibility mode. Returns the changed option as command params.
fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option<Value> {
let mut out = None;
let mut option = |ui: &mut Ui, key: &str, on: bool, label: &str| {
@@ -135,6 +157,17 @@ fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option<Value>
option(ui, "svgIdAttribute", e.svg_id_attribute, tr("Include id attribute"));
option(ui, "svgSimplifyStroke", e.svg_simplify_stroke, tr("Simplify stroke"));
}
if e.format == ExportFormat::VectorDrawable {
option(ui, "svgIdAttribute", e.svg_id_attribute, tr("Include id attribute"));
ui.add_space(4.0);
widgets::label(ui, tr("Compatibility"), Tokens::get(ui.ctx()).text_secondary);
for (mode, label) in VD_COMPAT {
let on = e.vd_compat == mode;
if widgets::radio(ui, on, tr(label)) && !on {
out = Some(json!({"vdCompat": mode.key()}));
}
}
}
out
}
@@ -340,8 +373,14 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) {
let ids = ids.to_vec();
app.pick_folder(tr("Export"), move |app, dir| {
if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy(), "assets": assets})) {
let n = r["files"].as_array().map_or(0, Vec::len);
app.toast(trn("Exported {n} files", n, &[]));
let files = r["files"].as_array().cloned().unwrap_or_default();
// Android Vector Drawables say what they changed or left out.
let changed = files.iter().any(|f| f["warnings"].as_array().is_some_and(|w| !w.is_empty()));
app.toast(if changed {
trn("Exported {n} files; some content was changed or left out", files.len(), &[])
} else {
trn("Exported {n} files", files.len(), &[])
});
}
});
}
@@ -363,8 +402,13 @@ pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting
}
app.pick_save(tr("Export"), file, &[ext], move |app, p| {
params["path"] = json!(p.to_string_lossy());
if app.run("file.export", params).is_some() {
app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())]));
if let Some(r) = app.run("file.export", params) {
let name = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
// Android Vector Drawables say what they changed or left out.
app.toast(match r["warnings"].as_array().map_or(0, Vec::len) {
0 => trf("Exported {name}", &[("name", &name)]),
n => trn("Exported {name}; {n} kinds of content were changed or left out", n, &[("name", &name)]),
});
}
});
}
+59 -1
View File
@@ -80,7 +80,7 @@ fn export_rows_offer_a_size_for_images_only() {
let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::unique(("export_size", 0)).with("menu")).is_some();
u.frame(vec![]);
assert!(size_menu(&u), "PNG has sizes");
for format in ["svg", "pdf"] {
for format in ["svg", "pdf", "xml"] {
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": format}));
u.frame(vec![]);
u.frame(vec![]);
@@ -94,6 +94,64 @@ fn export_rows_offer_a_size_for_images_only() {
}
}
/// The text painted this frame, with the centre of each piece.
fn labelled(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, egui::Pos2)> {
fn walk(shape: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
match shape {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
u.frame(vec![]);
let frame = u.frame_output(vec![]);
let mut out = Vec::new();
for c in &frame.shapes {
walk(&c.shape, &mut out);
}
out
}
fn click(u: &mut crate::layers_drag_tests::Ui, p: egui::Pos2) {
u.frame(vec![egui::Event::PointerMoved(p)]);
u.button(p, true);
u.button(p, false);
u.frame(vec![]);
}
/// Android Vector Drawable is in the format dropdown as "XML", and choosing it sets the format.
#[test]
fn the_format_dropdown_offers_android_vector_drawable() {
assert!(figcraft_doc::ExportFormat::ALL.contains(&figcraft_doc::ExportFormat::VectorDrawable));
let mut s = Session::new();
let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap());
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
u.frame(vec![]);
let nodes = labelled(&mut u);
let field = nodes.iter().find(|(l, _)| l == "PNG").map(|n| n.1).unwrap_or_else(|| panic!("no PNG dropdown in {nodes:?}"));
click(&mut u, field);
let nodes = labelled(&mut u);
let xml = nodes.iter().find(|(l, _)| l == "XML").map(|n| n.1).unwrap_or_else(|| panic!("no XML row in {nodes:?}"));
click(&mut u, xml);
let doc = u.app.session.doc().unwrap();
assert_eq!(doc.node(r).unwrap().exports[0].format, figcraft_doc::ExportFormat::VectorDrawable);
}
/// The File > Export dialog has an XML preset.
#[test]
fn the_export_dialog_has_an_xml_preset() {
let mut s = Session::new();
let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap());
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
u.frame(vec![]);
crate::menus::invoke(&mut u.app, "file.export");
let nodes = labelled(&mut u);
assert!(nodes.iter().any(|(l, _)| l == "XML"), "no XML preset in {nodes:?}");
}
/// The advanced options: Ignore overlapping layers (the `contentsOnly` setting), and Include
/// bounding box for text layers only. A setting that already has the bounding box on keeps it, and
/// the option stays so it can be turned off.
+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");
}
+3 -1
View File
@@ -1,6 +1,6 @@
# FigCraft architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the `vectordrawable` crate, L4: Android Vector Drawable export; earlier: the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
How FigCraft is built today. Engine first, a thin swappable UI, everything a command; a Figma-shaped
data model (a scene graph of frames with auto layout, components and variables). ~108,800 lines of
@@ -26,6 +26,7 @@ replaced.
| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`) |
| L4 | `figcraft-svg` | ~3.7k | SVG export and import (usvg) |
| L4 | `figcraft-pdf` | ~2.3k | PDF export (krilla) with print options |
| L4 | `figcraft-vectordrawable` | ~1.2k | Android Vector Drawable (XML) export: baked path data, gradients through `aapt:attr`, clip groups, compatibility modes and warnings |
| L5 | `figcraft-engine` | ~39.3k | Sessions, undo history (`Arc` snapshots), the command registry (`cmd/*`, `spec!`), pointer tools and vector edit mode, snapping and measuring, components sync, libraries, prototype runtime, Dev Mode code generation, the menu catalog (`catalog.rs`) and the keymap (`keymap.rs`), a panic guard |
| L6 | `figcraft-ui-egui` | ~31.9k | The UI3-style frontend: canvas (render worker), toolbar, layers, Design/Prototype/Inspect panels, colour picker, menus, dialogs, presenter, comments, accessibility (AccessKit), the control channel server |
| L6 | `figcraft-mcp` | ~2.4k | MCP server (JSON-RPC over stdio), headless or bridged to a running app |
@@ -81,6 +82,7 @@ input.
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | minor | Added `vectordrawable` (L4), the Android Vector Drawable (XML) writer |
| 2026-10-11 | minor | `collab-net` hosts through one hub for the app's sessions and the standalone server; added the `figcraft-server` app |
| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) |
| 2026-10-10 | major | Created from the code as of PR #99 |
+8 -2
View File
@@ -64,7 +64,7 @@ page 1 "Screens": 380 nodes, 4 top-level
…
```
### `export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]`
### `export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]`
- `<out>` is a directory: one file per top-level frame of the page (default the first page), named
after the frame (`Home.png`, `Settings.png`…); with no frames, one file per top-level layer.
@@ -80,9 +80,15 @@ page 1 "Screens": 380 nodes, 4 top-level
documents and small test files, in other software; other apps may not open every file. What was
left out (comments, Dev Mode data, some bindings and settings…) is printed as `note:` lines;
`docs/file-format-parity.md` lists what was checked and what is unchecked.
- `--format xml` (or an `<out>` ending in `.xml`) writes an Android Vector Drawable, e.g.
`figcraft-cli export app.figcraft --node Icon -o ic_icon.xml`. What the format can't draw is
printed as `note:` lines; `--vd-compat` picks how it's handled: `bestEffort` (default:
approximated), `exact` (left out) or `ignore` (no notes: overlapping layer opacity is dropped,
even-odd fills keep `fillType="evenOdd"`, diamond gradients are left out). See
`docs/file-format-parity.md`.
- The format comes from `--format`, else `<out>`'s extension, else png. `--scale` (default 1, max 64)
applies to raster formats.
- SVG/PDF exports with visible descendants more than 256 levels below an exported root fail with
- SVG/PDF/XML exports with visible descendants more than 256 levels below an exported root fail with
an error instead of writing an incomplete file. Select an inner layer with `--node` or reduce
nesting. An existing destination file is kept on this failure.
- A `.pdf` `<out>` without `--node` holds the page's top-level frames, one PDF page each in reading
+3 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (`.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -55,6 +55,7 @@ export limit, not a native document nesting limit.
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
| WebP | — | yes (ours) | | — |
| Android Vector Drawable (XML) | — | yes (ours) | an `.xml` drawable resource (`file.export {format: "xml"}`, export settings, File ▸ Export…, the CLI and MCP), pretty-printed, sized in dp with the viewport in layer units, for AndroidX (VectorDrawableCompat: gradients and even-odd need API 24 without it). Shapes, booleans and text as path data with transforms baked in; solid fills and linear, radial and sweep gradients (`aapt:attr`, exact for any handles: radial and sweep in a `<group>` that maps the unit circle); centred strokes as stroke attributes where Android draws them the same (uniform scale, one cap, no dashes or variable width), other strokes as filled outlines; clipping frames and masks as `<group>` with `<clip-path>`; `android:name` from layer names with the id attribute option. What the format can't hold goes by the compatibility mode (`vdCompat`): best effort (default) applies layer opacity to each shape, rewrites even-odd fills and clip outlines as non-zero, draws diamond gradients as radial and alpha masks as outline clips, each with a warning; exact leaves out what it can't draw exactly, with a warning; ignore writes what maps without warnings: layer opacity over overlapping shapes is dropped (drawn opaque), even-odd fills keep `fillType="evenOdd"`, even-odd clip outlines clip as non-zero, diamond gradients are left out and alpha masks clip to their outline. Effects, image and pattern fills and blend modes are always left out (warned except in ignore); an export that draws nothing writes one empty path (Android refuses a drawable without a path), with a warning. Checked by parsing the XML, against FigCraft's renderer (gradients pixel by pixel), with `aapt2 compile` and `link`, and by rendering samples (linear, skewed radial and sweep gradients, a sweep with mirrored handles, even-odd booleans, centred and inside strokes, rotated text, a clipping frame, an alpha mask, a translucent group, each compatibility mode) with Android 13's (API 33) VectorDrawable on an emulator, next to FigCraft's PNG export: they match, including where the sweep starts and which way it turns, except what the compatibility mode and its warnings say (best effort: overlapping shapes in a translucent group look denser; exact and ignore leave out or draw opaque what they list). Not checked on API 21–23 (VectorDrawableCompat) | — |
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML, React, Leptos and Vue | see [dev-mode-parity](dev-mode-parity.md) | — |
| `.fig` ("Save local copy") | yes | **experimental** | the whole file, also as a copy from File ▸ Save as… (the document keeps its `.figcraft` path and unsaved state); see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width | 5–15 (non-colour bindings as bindings, checks of effects, images and prototypes, and against real files) |
@@ -190,6 +191,7 @@ each.
| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 68 and 83 open; the Sketch 47 edition is refused). Numbers unchanged |
| 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged |
| 2026-10-20 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`): fixed px, fractional fill and content-sized tracks keep their sizes and come back through our own importer (#465). Tracks whose size the format can't hold (a negative or out-of-range value) keep their id and read back equal, with a warning. Numbers unchanged |
| 2026-10-18 | minor | Android Vector Drawable (XML) export, ours: the format, its compatibility modes (`vdCompat`: best effort, exact, ignore) and warnings; a `.fig` export writes XML export settings as SVG, with a note. Numbers unchanged |
| 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged |
| 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged |
| 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged |
+1 -1
View File
@@ -315,7 +315,7 @@ Or in a project's `.mcp.json`:
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
| `select {ids}` | Replace the selection (`[]` deselects). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. |
| `undo` / `redo` | Step through history. |
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -131,6 +131,7 @@ blockers.
| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
| 2026-10-18 | minor | M6 interop: Android Vector Drawable (XML) export (new L4 crate `figcraft-vectordrawable`), not on the incumbent's list. Numbers unchanged |
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). Numbers unchanged. |
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
+2 -1
View File
File diff suppressed because one or more lines are too long
+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),