mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 04:46:50 -06:00
An experimental, best-effort `.fig` export, so work done in FigCraft can be handed back as a `.fig` file. It is written from the same public format write-ups the importer uses and is the inverse of the importer's mapping. Only round trips through FigCraft's own importer are verified; other apps may not open every file.
- **Encoder** (`crates/format/src/fig/kiwi.rs`): the kiwi encoder that test fixtures used is now shipped code. It never panics and checks every value against the schema: unknown fields or enum names, missing struct fields, wrong types, out-of-range numbers and non-finite floats are errors, and NULs inside strings are dropped. Varints and floats are written exactly the way the decoder reads them. `Schema::parse` reads the compact text schema form, and the importer's tests use it too.
- **Writer** (`crates/format/src/fig/export.rs`): writes the zip (`canvas.fig`, `meta.json`, `thumbnail.png`, `images/<hash>`). `canvas.fig` holds the fig-kiwi header, then a raw-deflate binary schema and a raw-deflate message (flate2's pure-Rust backend, already in the tree; no new dependencies). The schema covers only the fields we write. The canvas, images, blobs, node count and nesting depth are capped, and floats are made finite before they are written.
- **Mapping:** pages; frames, groups, sections, rectangles, ellipses (arcs), polygons, stars, lines and vectors (vector network blobs with per-point radius, mirroring and caps, plus fill outlines); text with styled runs (UTF-16 indexed) and list types; slices; boolean groups; solid, gradient and image paints (image bytes included); strokes; effects; corners; constraints; masks; auto layout, including grids with tracks and cells; child layout; layout grids; export settings; components, component sets and their properties. Instances are written as their main plus the fields their layers change, by id path. This covers nested instances and swaps, and fields the main sets but the instance clears. Prototype interactions (triggers, navigation and overlay actions, transitions, easing), scrolling and flow starts are included. Everything skipped is reported in a warnings list: style links, variables, comments, Dev Mode data, plugin data, guides, slots, brush and width-profile strokes, pattern fills, image adjustments, variable and conditional actions, and more.
- **Import fixes found by the round trips** (`map.rs`): ellipse arcs, boolean operation types and component set descriptions are now read. Durations are read as whole milliseconds, because seconds stored as 32-bit floats used to truncate by 1 ms.
- **File writes:** a failed write no longer leaves its `.tmp-figcraft` file behind. The junk-parameter sweep found this.
- **Commands and UI:**
- `file.export {format: "fig"}`, or any path ending in `.fig`, writes the whole file and returns `warnings`. Without a path it returns base64 `data`.
- File > Export... has a new "The whole file" section with a ".fig (experimental)" row. A note under it says other apps may not open every exported file. After export, a toast reports how many kinds of content were left out.
- `figcraft-cli export --format fig` (or `-o x.fig`) and `convert` to `.fig` print the warnings as `note:` lines.
- MCP's `export` accepts `format: "fig"` and passes the warnings through.
- New UI strings have English and Spanish entries.
- **Docs:** file-format-parity.md has a new export section covering what is written, what is skipped, and that only round trips through our own importer are verified. Also updated: mcp.md, cli.md, gaps.md (gap 1), target-app-parity.md and ROADMAP.md. Grading is kept low. Import/export goes to 86%/58% and file formats to 60%. The file-exchange discount stays at x0.85, because nothing shows that another app opens these files.
383 lines
15 KiB
Rust
383 lines
15 KiB
Rust
//! The file subcommands: info, export, convert, render and run.
|
||
|
||
use std::path::Path;
|
||
use std::process::ExitCode;
|
||
|
||
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
|
||
use figcraft_engine::Session;
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::{CliError, CliResult};
|
||
|
||
pub fn load(path: &str) -> CliResult<Document> {
|
||
load_noted(path).map(|(d, _)| d)
|
||
}
|
||
|
||
/// The document with the loader's notes (repairs of a damaged file, or import warnings).
|
||
fn load_noted(path: &str) -> CliResult<(Document, Vec<String>)> {
|
||
figcraft_engine::cmd::file::load(path).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))
|
||
}
|
||
|
||
fn ext(path: &str) -> String {
|
||
Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||
}
|
||
|
||
fn image_format(e: &str) -> Option<ExportFormat> {
|
||
match e {
|
||
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
|
||
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
|
||
}
|
||
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
|
||
}
|
||
|
||
fn page_named(doc: &Document, name: Option<&str>) -> CliResult<NodeId> {
|
||
match name {
|
||
None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())),
|
||
Some(n) => doc
|
||
.pages
|
||
.iter()
|
||
.copied()
|
||
.find(|p| doc.node(*p).is_some_and(|pn| pn.name == n))
|
||
.or_else(|| doc.pages.iter().copied().find(|p| doc.node(*p).is_some_and(|pn| pn.name.eq_ignore_ascii_case(n))))
|
||
.ok_or_else(|| {
|
||
let names: Vec<String> = doc.pages.iter().filter_map(|p| doc.node(*p).map(|n| n.name.clone())).collect();
|
||
CliError::Failed(format!("no page named `{n}` (pages: {})", names.join(", ")))
|
||
}),
|
||
}
|
||
}
|
||
|
||
fn find_node(doc: &Document, page: NodeId, q: &str) -> CliResult<NodeId> {
|
||
if let Ok(id) = q.parse::<NodeId>()
|
||
&& doc.contains(id)
|
||
{
|
||
return Ok(id);
|
||
}
|
||
doc.find_by_name(q, Some(page))
|
||
.into_iter()
|
||
.find(|i| doc.node(*i).is_some_and(|n| n.name == q))
|
||
.or_else(|| doc.find_by_name(q, None).into_iter().find(|i| doc.node(*i).is_some_and(|n| n.name == q)))
|
||
.or_else(|| doc.find_by_name(q, Some(page)).into_iter().next())
|
||
.ok_or_else(|| CliError::Failed(format!("no layer with id or name `{q}`")))
|
||
}
|
||
|
||
fn children(doc: &Document, page: NodeId) -> Vec<NodeId> {
|
||
doc.node(page).map(|p| p.children.clone()).unwrap_or_default()
|
||
}
|
||
|
||
/// Render a page's content (with the page background) at `scale`, shrinking to fit 16384 px.
|
||
pub fn render_page(doc: &Document, page: NodeId, scale: f64) -> CliResult<figcraft_render::Pixmap> {
|
||
let b = doc.page_content_bounds(page).ok_or_else(|| CliError::Failed("the page is empty: nothing to render".into()))?;
|
||
let side = b.width().max(b.height()).max(1e-6);
|
||
let scale = scale.min(16_384.0 / side);
|
||
let view = figcraft_render::View {
|
||
origin: (b.x0, b.y0),
|
||
scale,
|
||
width: (b.width() * scale).ceil().max(1.0) as u32,
|
||
height: (b.height() * scale).ceil().max(1.0) as u32,
|
||
};
|
||
figcraft_render::render_view(doc, page, &view, &Default::default()).ok_or_else(|| CliError::Failed("render failed".into()))
|
||
}
|
||
|
||
/// Encode a render as the format extension `e` names, tagged with `doc`'s colour profile.
|
||
fn encode(doc: &Document, pix: &figcraft_render::Pixmap, e: &str) -> CliResult<Vec<u8>> {
|
||
let format = ExportFormat::from_ext(e).unwrap_or_default();
|
||
figcraft_render::encode_export(pix, format, doc.color_profile).map_err(CliError::Failed)
|
||
}
|
||
|
||
/// Write `doc` to `out`, choosing the format by extension.
|
||
pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
|
||
let e = ext(out);
|
||
let bytes = match e.as_str() {
|
||
"figcraft" => {
|
||
let thumb = figcraft_engine::cmd::file::thumbnail(doc);
|
||
figcraft_format::save_bytes(doc, thumb.as_deref()).map_err(|e| CliError::Failed(e.to_string()))?
|
||
}
|
||
"json" => {
|
||
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
|
||
}
|
||
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
|
||
.into_bytes(),
|
||
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
|
||
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
|
||
"fig" => fig_bytes(doc)?,
|
||
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
|
||
};
|
||
write(out, &bytes)
|
||
}
|
||
|
||
/// `doc` as a `.fig` file (experimental), printing what was left out.
|
||
fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
|
||
let (bytes, warnings) = figcraft_engine::cmd::file::fig_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||
for w in warnings {
|
||
eprintln!("note: {w}");
|
||
}
|
||
Ok(bytes)
|
||
}
|
||
|
||
/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page.
|
||
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
|
||
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
|
||
figcraft_engine::cmd::file::frames_pdf(doc, page, print).map(|(bytes, _)| bytes)
|
||
} else {
|
||
figcraft_engine::cmd::file::export_bytes_with(
|
||
doc,
|
||
&children(doc, page),
|
||
&ExportSetting { format: ExportFormat::Pdf, print: *print, ..Default::default() },
|
||
1.0,
|
||
)
|
||
};
|
||
r.map_err(|e| CliError::Failed(e.to_string()))
|
||
}
|
||
|
||
pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||
let (doc, notes) = load_noted(path)?;
|
||
let pages: Vec<Value> = doc
|
||
.pages
|
||
.iter()
|
||
.filter_map(|p| {
|
||
let pn = doc.node(*p)?;
|
||
let nodes = doc.subtree(*p).len().saturating_sub(1);
|
||
let frames: Vec<Value> = pn
|
||
.children
|
||
.iter()
|
||
.filter_map(|c| doc.node(*c))
|
||
.filter(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))
|
||
.map(|n| json!({"id": n.id, "name": n.name, "type": n.kind.type_name(), "width": n.width, "height": n.height}))
|
||
.collect();
|
||
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
|
||
})
|
||
.collect();
|
||
let v = json!({
|
||
"file": path,
|
||
"name": doc.name,
|
||
"nodes": doc.node_count(),
|
||
"pages": pages,
|
||
"styles": doc.styles.len(),
|
||
"variables": doc.variables.variables.len(),
|
||
"images": doc.images.len(),
|
||
"notes": notes,
|
||
});
|
||
if as_json {
|
||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||
return Ok(ExitCode::SUCCESS);
|
||
}
|
||
for n in ¬es {
|
||
eprintln!("note: {n}");
|
||
}
|
||
println!(
|
||
"{} — {} ({} nodes, {} styles, {} variables, {} images)",
|
||
path,
|
||
doc.name,
|
||
doc.node_count(),
|
||
doc.styles.len(),
|
||
doc.variables.variables.len(),
|
||
doc.images.len()
|
||
);
|
||
for p in &pages {
|
||
println!("page {} \"{}\": {} nodes, {} top-level", p["id"], p["name"].as_str().unwrap_or(""), p["nodes"], p["topLevel"]);
|
||
for f in p["frames"].as_array().into_iter().flatten() {
|
||
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
|
||
}
|
||
}
|
||
Ok(ExitCode::SUCCESS)
|
||
}
|
||
|
||
fn sanitize(name: &str) -> String {
|
||
let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
|
||
let s = s.trim().trim_matches('.').to_string();
|
||
if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() }
|
||
}
|
||
|
||
/// What `export` exports and how.
|
||
pub struct Target {
|
||
pub format: Option<String>,
|
||
pub scale: f64,
|
||
pub page: Option<String>,
|
||
pub node: Option<String>,
|
||
/// PDF only.
|
||
pub print: PrintSettings,
|
||
}
|
||
|
||
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||
let doc = load(file)?;
|
||
// `.fig`: the whole file.
|
||
if t.format.as_deref().map_or(ext(out) == "fig", |f| f.eq_ignore_ascii_case("fig")) {
|
||
write(out, &fig_bytes(&doc)?)?;
|
||
println!("{out}");
|
||
return Ok(ExitCode::SUCCESS);
|
||
}
|
||
let page = page_named(&doc, t.page.as_deref())?;
|
||
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)")))?
|
||
}
|
||
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()))
|
||
};
|
||
if single {
|
||
let bytes = match target {
|
||
Some(t) => bytes_of(&[t])?,
|
||
None if fmt == ExportFormat::Pdf => page_pdf(&doc, page, print)?,
|
||
None => bytes_of(&children(&doc, page))?,
|
||
};
|
||
write(out, &bytes)?;
|
||
println!("{out}");
|
||
return Ok(ExitCode::SUCCESS);
|
||
}
|
||
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
|
||
let ids = match target {
|
||
Some(t) => vec![t],
|
||
None => {
|
||
let kids = children(&doc, page);
|
||
let frames: Vec<NodeId> = kids.iter().copied().filter(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())).collect();
|
||
if frames.is_empty() { kids } else { frames }
|
||
}
|
||
};
|
||
if ids.is_empty() {
|
||
return Err(CliError::Failed("the page is empty: nothing to export".into()));
|
||
}
|
||
let mut used = std::collections::BTreeSet::new();
|
||
for id in ids {
|
||
let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer"));
|
||
let mut name = base.clone();
|
||
let mut k = 2;
|
||
while !used.insert(name.to_lowercase()) {
|
||
name = format!("{base} {k}");
|
||
k += 1;
|
||
}
|
||
let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned();
|
||
write(&path, &bytes_of(&[id])?)?;
|
||
println!("{path}");
|
||
}
|
||
Ok(ExitCode::SUCCESS)
|
||
}
|
||
|
||
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
|
||
let doc = load(input)?;
|
||
let page = doc
|
||
.current_page
|
||
.filter(|p| doc.pages.contains(p))
|
||
.or_else(|| doc.first_page())
|
||
.ok_or_else(|| CliError::Failed("the document has no pages".into()))?;
|
||
write_doc(&doc, page, out)?;
|
||
println!("{out}");
|
||
Ok(ExitCode::SUCCESS)
|
||
}
|
||
|
||
pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResult<ExitCode> {
|
||
let doc = load(file)?;
|
||
let page = page_named(&doc, page)?;
|
||
let e = ext(out);
|
||
if !matches!(e.as_str(), "png" | "jpg" | "jpeg" | "webp") {
|
||
return Err(CliError::Usage(format!("render writes .png, .jpg or .webp, not `{out}`")));
|
||
}
|
||
let pix = render_page(&doc, page, scale)?;
|
||
write(out, &encode(&doc, &pix, &e)?)?;
|
||
println!("{out} ({}×{})", pix.width(), pix.height());
|
||
Ok(ExitCode::SUCCESS)
|
||
}
|
||
|
||
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
|
||
pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
|
||
Ok(Value::Array(a)) => a,
|
||
Ok(Value::Object(o)) if o.contains_key("commands") => {
|
||
o.get("commands").and_then(Value::as_array).cloned().ok_or("`commands` must be an array")?
|
||
}
|
||
Ok(v @ Value::Object(_)) => vec![v],
|
||
Ok(_) => return Err("a script is an array of {\"command\", \"params\"} objects".into()),
|
||
Err(_) => text
|
||
.lines()
|
||
.enumerate()
|
||
.filter(|(_, l)| !l.trim().is_empty() && !l.trim_start().starts_with("//"))
|
||
.map(|(i, l)| serde_json::from_str::<Value>(l).map_err(|e| format!("line {}: {e}", i + 1)))
|
||
.collect::<Result<_, _>>()?,
|
||
};
|
||
steps
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(i, s)| {
|
||
let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?;
|
||
let params = match s.get("params") {
|
||
None | Some(Value::Null) => json!({}),
|
||
Some(p @ Value::Object(_)) => p.clone(),
|
||
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
|
||
};
|
||
Ok((cmd.to_string(), params))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
|
||
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||
let mut s = match input {
|
||
Some(path) => {
|
||
let mut s = Session::empty();
|
||
s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?;
|
||
s
|
||
}
|
||
None => Session::new(),
|
||
};
|
||
for (i, (cmd, params)) in steps.iter().enumerate() {
|
||
let r = s.execute(cmd, params);
|
||
s.ui_requests.clear();
|
||
match r {
|
||
Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})),
|
||
Err(e) => {
|
||
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()}));
|
||
eprintln!("error: step {i} ({cmd}) failed: {e}");
|
||
return Ok(ExitCode::from(1));
|
||
}
|
||
}
|
||
}
|
||
if let Some(out) = out {
|
||
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
|
||
if ext(out) == "figcraft" {
|
||
// Through the engine, so the version history is kept.
|
||
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
|
||
} else {
|
||
write_doc(&ds.doc, ds.page, out)?;
|
||
}
|
||
eprintln!("wrote {out}");
|
||
}
|
||
Ok(ExitCode::SUCCESS)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn script_formats() {
|
||
assert_eq!(parse_script(r#"[{"command":"a"},{"command":"b","params":{"x":1}}]"#).unwrap().len(), 2);
|
||
assert_eq!(parse_script(r#"{"commands":[{"command":"a"}]}"#).unwrap().len(), 1);
|
||
assert_eq!(parse_script("{\"command\":\"a\"}\n\n// note\n{\"command\":\"b\"}\n").unwrap().len(), 2);
|
||
assert!(parse_script("[{\"params\":{}}]").is_err());
|
||
assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err());
|
||
assert!(parse_script("not json\n").is_err());
|
||
assert!(parse_script("42").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn sanitizes_names() {
|
||
assert_eq!(sanitize("Home / Hero"), "Home _ Hero");
|
||
assert_eq!(sanitize("../.."), "_");
|
||
assert_eq!(sanitize(""), "layer");
|
||
}
|
||
}
|