mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
file.saveAs and document.save wrote a PDF-compatible .ai, but document.export (and with it the CLI's run --export x.ai and convert in.svg out.ai, and MCP's export) refused it as a format that can only be opened, and --help didn't list it as writable. The .ai encoder is now shared: save.rs's ai_params and encode_ai (a PDF of every artboard with the PDF options, carrying the native document, blank pages with pdfCompatible: false) serve Save As and the exports alike. The .ai format is writable, so document.formats, --help and the MCP export tool list it; an export leaves the document's own path alone. Export As and Export for Screens keep leaving .ai to Save As. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
7.4 KiB
Rust
161 lines
7.4 KiB
Rust
//! File → Export → Export As…: the format and Use Artboards (All or a range: one file per
|
|
//! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK
|
|
//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG
|
|
//! Options, Text Export Options, DXF Options, EPS Options, and Save PDF for artboards as pages),
|
|
//! else the file is written. DWG is listed, greyed out, with what to use instead.
|
|
|
|
use std::sync::LazyLock;
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
|
|
|
|
use super::{DialogSpec, dxf_options, eps_options, form, png_options, save_pdf, svg_options, text_export};
|
|
use crate::state::Dialog;
|
|
use crate::theme::Tokens;
|
|
use crate::{VectorcraftApp, io, widgets};
|
|
|
|
pub(super) const SPEC: DialogSpec =
|
|
DialogSpec { heading: |_| tl!("Export As").into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM };
|
|
|
|
/// The formats Export As writes (the native format and `.ai` are Save As's).
|
|
static FORMATS: LazyLock<Vec<&'static Format>> =
|
|
LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && !matches!(f.id, "vectorcraft" | "ai")).collect());
|
|
/// Formats listed after them that can't be written (`fileio::unsupported` ids), greyed out.
|
|
const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")];
|
|
static LABELS: LazyLock<Vec<&'static str>> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).chain(UNAVAILABLE.iter().map(|u| u.1)).collect());
|
|
|
|
/// What to use instead when the field names a format that can't be written (DWG).
|
|
fn unavailable(d: &Dialog) -> Option<(&'static str, &'static str)> {
|
|
let id = d.str("format");
|
|
let (_, label) = UNAVAILABLE.iter().find(|u| u.0 == id)?;
|
|
Some((label, fileio::unsupported(&id)?.hint))
|
|
}
|
|
|
|
/// Open the dialog on `format` (an id or extension, default PNG); `p` may preset `useArtboards`
|
|
/// and `range`.
|
|
pub fn open(app: &mut VectorcraftApp, format: Option<&str>, p: &Value) {
|
|
let n = app.session.active().map_or(0, |s| s.doc.artboards.len());
|
|
let missing = format.and_then(|f| UNAVAILABLE.iter().find(|u| u.0.eq_ignore_ascii_case(f.trim_start_matches('.'))));
|
|
let format = match missing {
|
|
Some((id, _)) => id,
|
|
None => format.and_then(fileio::format).filter(|f| FORMATS.iter().any(|g| g.id == f.id)).map_or("png", |f| f.id),
|
|
};
|
|
let range = p.get("range").and_then(Value::as_str);
|
|
app.ui.dialog = Some(Dialog::new(
|
|
"exportAs",
|
|
json!({
|
|
"format": format,
|
|
"useArtboards": p.get("useArtboards").and_then(Value::as_bool).unwrap_or(false),
|
|
"all": range.is_none(),
|
|
"range": range.map_or_else(|| if n > 1 { format!("1-{n}") } else { "1".into() }, str::to_string),
|
|
}),
|
|
));
|
|
}
|
|
|
|
/// The chosen format (PNG when the field names none Export As writes).
|
|
fn chosen(d: &Dialog) -> Option<&'static Format> {
|
|
let id = d.str("format");
|
|
let find = |id: &str| FORMATS.iter().copied().find(|f| f.id == id);
|
|
find(&id).or_else(|| find("png"))
|
|
}
|
|
|
|
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|
let t = Tokens::get(ui.ctx());
|
|
let Some(f) = chosen(d) else { return false };
|
|
let missing = unavailable(d);
|
|
egui::Grid::new("export-as").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
|
|
ui.label(egui::RichText::new(tl!("Format:")).color(t.text_dim));
|
|
let shown = missing.map_or(f.label, |(label, _)| label);
|
|
if let Some(g) = widgets::dropdown_with(ui, "export-as-format", shown, &LABELS, 160.0, |i| i < FORMATS.len()).and_then(|i| FORMATS.get(i)) {
|
|
d.fields.insert("format".into(), json!(g.id));
|
|
}
|
|
ui.end_row();
|
|
});
|
|
if let Some((_, hint)) = missing {
|
|
ui.add_space(6.0);
|
|
form::caption(ui, hint);
|
|
}
|
|
ui.add_space(10.0);
|
|
form::check(ui, d, "useArtboards", tl!("Use Artboards"));
|
|
let use_artboards = d.bool("useArtboards");
|
|
ui.add_enabled_ui(use_artboards, |ui| {
|
|
ui.horizontal(|ui| {
|
|
ui.add_space(22.0);
|
|
let mut all = d.bool("all");
|
|
if ui.radio_value(&mut all, true, tl!("All")).changed() | ui.radio_value(&mut all, false, tl!("Range:")).changed() {
|
|
d.fields.insert("all".into(), json!(all));
|
|
}
|
|
let mut range = d.str("range");
|
|
if ui.add_enabled(!all, egui::TextEdit::singleline(&mut range).desired_width(90.0)).changed() {
|
|
d.fields.insert("range".into(), json!(range));
|
|
}
|
|
});
|
|
});
|
|
ui.add_space(8.0);
|
|
let ext = f.extensions[0];
|
|
let hint = match (use_artboards, f.id) {
|
|
(true, "pdf") => tl!("One page per artboard.").to_string(),
|
|
(true, _) => crate::i18n::fmt(tl!("One file per artboard: <name>-<artboard>.{ext}"), &[("ext", ext)]),
|
|
(false, _) => tl!("The bounds of the visible art.").to_string(),
|
|
};
|
|
ui.label(egui::RichText::new(hint).color(t.text_dim).size(11.5));
|
|
false
|
|
}
|
|
|
|
/// Check the artboard range, pick the file, then show the format's options or write the file.
|
|
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|
if let Some((_, hint)) = unavailable(d) {
|
|
return Err(hint.into());
|
|
}
|
|
let f = chosen(d).ok_or("no format to export to")?;
|
|
let use_artboards = d.bool("useArtboards");
|
|
let all = d.bool("all");
|
|
let range = d.str("range");
|
|
if use_artboards && !all {
|
|
// A bad range keeps the dialog open.
|
|
let n = app.session.active().map_or(0, |s| s.doc.artboards.len());
|
|
ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?;
|
|
}
|
|
// Nothing to export keeps the dialog open too (before the save dialog, not after it).
|
|
if !use_artboards && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) {
|
|
return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into());
|
|
}
|
|
app.ui.dialog = None;
|
|
let path = io::target_path(app, None, f.extensions[0])?;
|
|
let range = if all { "all".to_string() } else { range };
|
|
match f.id {
|
|
"svg" | "svgz" => {
|
|
svg_options::open(app, svg_options::Mode::Export, Some(&path));
|
|
if let Some(o) = app.ui.dialog.as_mut() {
|
|
o.fields.extend([("useArtboards".into(), json!(use_artboards)), ("allArtboards".into(), json!(all)), ("range".into(), json!(range))]);
|
|
}
|
|
Ok(Value::Null)
|
|
}
|
|
// Artboards as pages go on in Save PDF; the art's bounds are written straight away.
|
|
"pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })),
|
|
"txt" => {
|
|
text_export::open(app, &path);
|
|
Ok(Value::Null)
|
|
}
|
|
_ => {
|
|
let mut params = json!({ "format": f.id, "path": path, "useArtboards": use_artboards });
|
|
if use_artboards {
|
|
params["range"] = json!(range);
|
|
}
|
|
if f.raster {
|
|
png_options::open(app, f, params);
|
|
return Ok(Value::Null);
|
|
}
|
|
if f.id == "dxf" {
|
|
dxf_options::open(app, ¶ms);
|
|
return Ok(Value::Null);
|
|
}
|
|
if f.id == "eps" {
|
|
eps_options::open(app, ¶ms);
|
|
return Ok(Value::Null);
|
|
}
|
|
io::export(app, Some(f.id), Some(path), ¶ms)
|
|
}
|
|
}
|
|
}
|