Export As: a PDF without artboards opens Save PDF and its options

Export As starts with Use Artboards off. For a PDF it then wrote one
page of the art's bounds straight away with the default preset, so the
Save PDF dialog never showed and none of its options applied there,
"Write pages longer than 200 in at full size in points" included.

Export As now opens Save PDF with Use Artboards on or off. With it off,
the dialog has no Artboards section, and OK writes one page of the
visible art's bounds the way Export As writes its other formats: with
Background Export, the progress dialog and its Cancel, and the question
before replacing the file the document was opened from. View PDF after
Saving opens that file too.

document.exportPdf listed useArtboards among its options but did not
read it. false now writes the page that document.export writes, and
ignores artboard, artboards and range; both commands use one helper
for it. document.pdfSettings refuses a useArtboards that isn't true or
false, and with includeDocument it exports that page, so the Summary's
warnings are about it.

While an opacity mask or symbol is being edited, document.exportPdf
and document.pdfSettings leave out the editing layer, from the drawn
pages and from the Preserve Editing data, as document.export does.

With the artboards as pages too, Save PDF's OK over the file the
document was opened from, when opening it left things out, now asks
first (file.export.pdf), and the question stays open when the dialog
closes.
This commit is contained in:
dotCooCoo
2026-11-12 09:54:09 -08:00
parent a15a4abc5c
commit e2ddeae50c
13 changed files with 301 additions and 35 deletions
+24 -11
View File
@@ -321,15 +321,31 @@ pub(crate) fn anti_alias(id: &str) -> std::result::Result<AntiAlias, String> {
/// The `useArtboards` param of a PDF, AI, DXF or raster export (SVG has its own): `true` writes every
/// chosen artboard (default all), `false` the bounds of the visible art instead, absent one
/// artboard (raster, DXF) or the chosen pages (PDF, AI).
fn use_artboards(p: &Value) -> Result<Option<bool>> {
/// artboard (raster, DXF) or the chosen pages (PDF, AI). Errors name the command `cmd`.
pub(super) fn use_artboards(cmd: &str, p: &Value) -> Result<Option<bool>> {
match p.get("useArtboards") {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(v) => Err(bad(C, format!("useArtboards must be true or false, not {v}"))),
Some(v) => Err(bad(cmd, format!("useArtboards must be true or false, not {v}"))),
}
}
/// With `useArtboards: false` in `p`: `doc` without its opacity-mask or symbol editing layer and
/// with one artboard, the bounds of its visible art, and `p` without the artboard choice. `None`
/// when `useArtboards` is absent, null or true. A document with no visible art is refused.
pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(Document, Value)>> {
if use_artboards(cmd, p)? != Some(false) {
return Ok(None);
}
let doc = &*doc.without_edit_modes();
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p);
if let Some(o) = q.as_object_mut() {
o.remove("useArtboards");
}
Ok(Some((single_artboard(doc, bounds, "Art"), q)))
}
/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
@@ -503,7 +519,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let doc = &*doc.without_edit_modes();
let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(p)? } else { None };
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(C, p)? } else { None };
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
if let Some(rect) = page_rect(f, p)? {
let mut q = super::export::without_artboards(p);
@@ -513,13 +529,10 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
}
return encode_files(&single_artboard(doc, rect, "Rect"), f, &q);
}
if use_artboards == Some(false) {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p);
if let Some(o) = q.as_object_mut() {
o.remove("useArtboards");
}
return encode_files(&single_artboard(doc, bounds, "Art"), f, &q);
if use_artboards == Some(false)
&& let Some((art, q)) = art_page(C, doc, p)?
{
return encode_files(&art, f, &q);
}
let bytes = match f.id {
"vectorcraft" => super::native::encode(C, f, doc, p)?,
File diff suppressed because one or more lines are too long
+91
View File
@@ -56,6 +56,97 @@ fn export_pdf_writes_the_compatibility_header_and_the_range() {
assert!(b64(&v).starts_with(b"%PDF-1.6"));
}
/// `useArtboards: false` writes one page, the bounds of the visible art, as `document.export`
/// does, whatever artboards are chosen. Art longer than 200 in keeps its size with
/// `longPagesInPoints`. A document with no visible art and a value that isn't a boolean are
/// refused.
#[test]
fn use_artboards_false_writes_one_page_of_the_arts_bounds() {
let mut s = session(3);
let page = |bytes: &[u8]| {
let d = vectorcraft_pdf::import(bytes).unwrap();
(d.artboards.len(), d.artboards[0].rect.size())
};
let through_export = page(&b64(&s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap()));
assert!(through_export.0 == 1 && through_export.1.width < 50.0 && through_export.1.height < 40.0, "{through_export:?}");
for p in [
json!({"useArtboards": false}),
json!({"useArtboards": false, "range": "2"}),
json!({"useArtboards": false, "artboards": [0, 1]}),
json!({"useArtboards": false, "artboard": 2}),
] {
assert_eq!(page(&b64(&export(&mut s, p.clone()))), through_export, "{p}");
}
assert_eq!(pages(&b64(&export(&mut s, json!({"useArtboards": true})))), 3);
assert_eq!(pages(&b64(&export(&mut s, json!({"useArtboards": null})))), 3);
// A 10 m drawing on a small artboard: its page is 10 m long, in points.
s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 28_346.46, "height": 100})).unwrap();
let v = export(&mut s, json!({"useArtboards": false, "longPagesInPoints": true}));
let pdf = b64(&v);
let (n, size) = page(&pdf);
assert!(n == 1 && size.width > 28_346.0 && size.width < 28_350.0, "{size:?}");
assert!(!String::from_utf8_lossy(&pdf).contains("/UserUnit"));
assert!(warnings(&v).iter().any(|w| w.contains("written without a UserUnit, in points")), "{v}");
// The Summary's export in memory writes the same page.
let mut long = |p: Value| warnings(&s.execute("document.pdfSettings", &p).unwrap()).iter().any(|w| w.contains("longer than PDF's page limit"));
assert!(long(json!({"includeDocument": true, "useArtboards": false})));
assert!(!long(json!({"includeDocument": true})), "the artboards are small");
let e = s.execute("document.pdfSettings", &json!({"useArtboards": "no"})).unwrap_err();
assert!(matches!(e, crate::EngineError::BadParams { .. }), "without includeDocument too: {e}");
s.execute("select.all", &json!({})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
for p in [json!({"useArtboards": false}), json!({"useArtboards": "no"})] {
let e = s.execute("document.exportPdf", &p).unwrap_err();
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}");
}
}
/// While an opacity mask is edited, a PDF of the art's bounds measures the art without the
/// editing layer, as `document.export` does: a mask shape drawn past the masked object doesn't
/// widen the page.
#[test]
fn a_pdf_of_the_art_leaves_out_the_mask_editing_layer() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let obj = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"].clone();
let mask = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [mask]})).unwrap();
s.execute("select.set", &json!({"ids": [obj, mask]})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
s.execute("shape.ellipse", &json!({"x": 250, "y": 250, "width": 40, "height": 40})).unwrap();
assert!(s.doc().unwrap().doc.mask_edit.is_some());
let width = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().artboards[0].rect.width();
let through_export = width(&b64(&s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap()));
assert!(through_export < 250.0, "{through_export}");
assert_eq!(width(&b64(&export(&mut s, json!({"useArtboards": false})))), through_export);
}
/// While an opacity mask is edited, a PDF of the artboards leaves out the editing layer too, as
/// `document.export` does: from the drawn page and from the Preserve Editing data.
#[test]
fn a_pdf_of_the_artboards_leaves_out_the_mask_editing_layer() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let obj = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"].clone();
let mask = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [mask]})).unwrap();
s.execute("select.set", &json!({"ids": [obj, mask]})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
s.execute("shape.ellipse", &json!({"x": 250, "y": 250, "width": 40, "height": 40})).unwrap();
assert!(s.doc().unwrap().doc.mask_edit.is_some());
let nodes = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().node_count();
let plain = json!({"preserveEditing": false});
let through_export = nodes(&b64(&s.execute("document.export", &as_export(&plain)).unwrap()));
assert_eq!(nodes(&b64(&export(&mut s, plain))), through_export, "the drawn page");
let layers = |v: &Value| vectorcraft_format::load(&vectorcraft_pdf::editing(&b64(v)).unwrap().data).unwrap().layers;
let through_export = layers(&s.execute("document.export", &as_export(&json!({}))).unwrap());
assert_eq!(layers(&export(&mut s, json!({}))), through_export, "the Preserve Editing data");
}
#[test]
fn bad_options_are_refused() {
let mut s = session(2);
+3 -2
View File
@@ -2,7 +2,7 @@
//! artboard, after a central file of the whole document for EPS with two or more when art shows,
//! 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),
//! Options, Text Export Options, DXF Options, EPS Options, and Save PDF),
//! else the file is written. DWG is listed, greyed out, with what to use instead. The formats of
//! installed exporter plug-ins come after the built-in ones: they write the whole document, and one
//! with parameters asks for them before writing (`plugin::open_export`).
@@ -169,8 +169,9 @@ fn options_or_write(
}
Ok(Value::Null)
}
// Artboards as pages go on in Save PDF; the art's bounds are written straight away.
// Save PDF writes the artboards as pages, or one page of the art's bounds.
"pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })),
"pdf" => save_pdf::open(app, &json!({ "path": path, "useArtboards": false })),
"txt" => {
text_export::open(app, &path);
Ok(Value::Null)
+32 -6
View File
@@ -5,6 +5,8 @@
//! plus `preset`, `range`, `path?` and UI-only `__` keys, so agents fill the dialog with
//! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is
//! `document.pdfSettings`. Save Preset… names the settings as a preset (`pdf.preset.save`).
//! Opened from Export As with Use Artboards off (`__artBounds`), the dialog has no Artboards
//! section, and OK writes one page of the visible art's bounds through [`io::export`].
//!
//! The same dialog edits a preset (kind `pdfPreset`, from Edit → PDF Presets): a `name` and
//! `description` instead of the preset and artboards, and OK saves the preset.
@@ -45,6 +47,9 @@ pub(super) const PRESET_SPEC: DialogSpec = DialogSpec {
const EDITING: &str = "__editing";
/// Save PDF's field holding the name typed for Save Preset… (present while that row shows).
const SAVE_AS: &str = "__savePresetAs";
/// Save PDF's field that is true when the page is the bounds of the visible art (Export As with
/// Use Artboards off) instead of the artboards.
const ART_BOUNDS: &str = "__artBounds";
pub(super) const SECTIONS: [&str; 7] = ["General", "Compression", "Marks and Bleeds", "Output", "Advanced", "Security", "Summary"];
@@ -76,7 +81,7 @@ pub(super) const LIST_WIDTH: f32 = 150.0;
pub(super) const TOP_LABEL_WIDTH: f32 = LIST_WIDTH + 26.0;
/// Open the dialog with `params` (`document.exportPdf` options and `path?`) applied over their
/// preset.
/// preset. `useArtboards: false` makes the page the bounds of the visible art.
pub fn open(app: &mut VectorcraftApp, params: &Value) -> Result<Value, String> {
let mut fields = settings_fields(app, params)?;
let s = |k: &str| params.get(k).and_then(Value::as_str);
@@ -88,6 +93,9 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) -> Result<Value, String> {
if let Some(path) = s("path") {
fields.insert("path".into(), json!(path));
}
if params.get("useArtboards") == Some(&json!(false)) {
fields.insert(ART_BOUNDS.into(), json!(true));
}
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(Value::Null)
}
@@ -117,10 +125,14 @@ fn settings_fields(app: &VectorcraftApp, params: &Value) -> Result<Map<String, V
/// The `document.exportPdf` params the dialog stands for.
fn params(d: &Dialog) -> Value {
let mut p = form::params(d);
if (d.bool("__allArtboards") || d.str("range").trim().is_empty())
&& let Some(o) = p.as_object_mut()
{
o.remove("range");
let art = d.bool(ART_BOUNDS);
if let Some(o) = p.as_object_mut() {
if art || d.bool("__allArtboards") || d.str("range").trim().is_empty() {
o.remove("range");
}
if art {
o.insert("useArtboards".into(), json!(false));
}
}
p
}
@@ -137,14 +149,24 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
let r = match SaveMode::of(&d.str("__save")) {
Some(mode) => io::save_pdf(app, mode, params(d)),
None if d.bool(ART_BOUNDS) => export_art(app, params(d)),
None => io::export_pdf(app, params(d)),
};
if r.is_ok() {
// A question the write asked (replace the file the document was opened from?) stays open.
if r.is_ok() && app.ui.dialog.as_ref().is_some_and(|o| o.kind == KIND) {
app.ui.dialog = None;
}
r
}
/// A page of the art's bounds (Export As with Use Artboards off): written as Export As writes
/// other formats ([`io::export`]), with the dialog's options and a saved preset written out.
fn export_art(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> {
let path = p.as_object_mut().and_then(|o| o.remove("path")).and_then(|v| v.as_str().map(str::to_string));
let p = pdf::expand_preset(&app.session, "file.exportAs", &p).map_err(|e| e.to_string())?.into_owned();
io::export(app, Some("pdf"), path, &p)
}
/// The settings as `pdf.preset.save` params (without the artboards and path).
fn preset_params(d: &Dialog) -> Value {
let mut p = form::params(d);
@@ -490,6 +512,10 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) {
}
return;
}
// A page of the art's bounds has no artboards to choose.
if d.bool(ART_BOUNDS) {
return;
}
heading(ui, tl!("Artboards"));
let mut all = d.bool("__allArtboards");
ui.horizontal(|ui| {
@@ -92,6 +92,72 @@ fn confirm_runs_export_pdf_with_the_dialog_options() {
assert!(app.ui.status.starts_with("Saved /tmp/out.pdf"), "{}", app.ui.status);
}
/// Opened for a page of the art's bounds (Export As with Use Artboards off), the dialog has no
/// Artboards section, and OK writes one page of the art's bounds with its options, a saved preset
/// and View PDF after Saving included.
#[test]
fn a_page_of_the_arts_bounds_has_no_artboards_section_and_takes_the_options() {
let (mut app, written, opened) = app();
app.run("pdf.preset.save", json!({"name": "Mine", "compatibility": "1.5"})).unwrap();
let ctx = egui::Context::default();
ctx.enable_accesskit();
theme::install_fonts(&ctx);
let has_range = |app: &mut VectorcraftApp| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
nodes.iter().any(|(_, n)| n.label() == Some("Range:") && n.role() == egui::accesskit::Role::RadioButton)
};
app.run("ui.savePdfDialog", json!({"path": "/tmp/artboards.pdf"})).unwrap();
has_range(&mut app);
assert!(has_range(&mut app), "the artboards' dialog has the section");
app.run("ui.savePdfDialog", json!({"path": "/tmp/art.pdf", "preset": "Mine", "useArtboards": false})).unwrap();
assert!(app.ui.dialog.as_ref().unwrap().bool(ART_BOUNDS));
has_range(&mut app);
assert!(!has_range(&mut app), "no artboards to choose");
set_field(&mut app, "viewAfterSaving", json!(true));
let r = super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "closes after saving");
assert_eq!(r["path"], "/tmp/art.pdf");
let w = written.borrow();
assert_eq!(w.len(), 1);
assert!(w[0].1.starts_with(b"%PDF-1.5"), "the saved preset reaches the writer");
let pdf = vectorcraft_pdf::import(&w[0].1).unwrap();
assert_eq!(pdf.artboards.len(), 1);
let size = pdf.artboards[0].rect.size();
assert!((50.0..52.0).contains(&size.width) && (40.0..42.0).contains(&size.height), "the rectangle's bounds with its stroke: {size:?}");
assert_eq!(opened.borrow().len(), 1, "View PDF after Saving opens the file");
assert!(opened.borrow()[0].0.ends_with("/tmp/art.pdf"));
}
/// With Background Export on, a page of the art's bounds is written in the background, as Export
/// As writes its other formats.
#[test]
fn a_page_of_the_arts_bounds_is_written_in_the_background() {
let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Vec<u8>)>::new()));
let w = written.clone();
let services = Services {
write_shared: Some(std::sync::Arc::new(move |p: &str, b: &[u8]| {
w.lock().unwrap().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.session.prefs.background_export = true;
app.run("file.new", json!({"width": 120, "height": 90})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
app.run("ui.savePdfDialog", json!({"path": "/tmp/bg.pdf", "useArtboards": false})).unwrap();
let r = super::super::confirm(&mut app).unwrap();
assert_eq!(r["background"], true, "{r}");
assert!(app.ui.dialog.is_none());
crate::background::wait_all(&mut app);
let w = written.lock().unwrap();
assert_eq!(w.len(), 1);
assert_eq!(w[0].0, "/tmp/bg.pdf");
assert_eq!(vectorcraft_pdf::import(&w[0].1).unwrap().artboards.len(), 1);
}
#[test]
fn warnings_reach_the_status_and_errors_keep_the_dialog_open() {
let (mut app, written, opened) = app();
+39 -7
View File
@@ -190,7 +190,7 @@ fn an_empty_export_as_range_shows_an_example() {
}
#[test]
fn svg_continues_in_svg_options_and_pdf_art_is_written_directly() {
fn svg_continues_in_svg_options() {
let (mut app, written) = app(2);
app.run("file.exportAs", json!({"format": "svg", "useArtboards": true})).unwrap();
confirm(&mut app).unwrap();
@@ -201,12 +201,35 @@ fn svg_continues_in_svg_options_and_pdf_art_is_written_directly() {
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(names(&written), ["/out/Untitled-1-Artboard-1.svg", "/out/Untitled-1-Artboard-2.svg"]);
// Without artboards: one file of the art's bounds, with no options dialog for a PDF.
}
/// Without Use Artboards, a PDF continues in Save PDF too, without its artboard choice, and the
/// file is one page of the art's bounds. Its options apply: a drawing 10 m long is written at
/// full size in points with "Write pages longer than 200 in at full size in points".
#[test]
fn pdf_art_continues_in_the_save_pdf_dialog() {
let (mut app, written) = app(2);
app.run("file.exportAs", json!({"format": "pdf"})).unwrap();
let r = confirm(&mut app).unwrap();
assert_eq!(r["path"], "/out/Untitled-1.pdf");
let pdf = vectorcraft_pdf::import(&written.borrow()[2].1).unwrap();
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some("savePdf"), "the PDF options follow");
assert_eq!(app.ui.dialog.as_ref().unwrap().str("path"), "/out/Untitled-1.pdf");
assert!(written.borrow().is_empty());
frame(&mut app);
assert_eq!(confirm(&mut app).unwrap()["path"], "/out/Untitled-1.pdf");
let pdf = vectorcraft_pdf::import(&written.borrow()[0].1).unwrap();
assert_eq!(pdf.artboards.len(), 1);
assert_eq!((pdf.artboards[0].rect.width().round(), pdf.artboards[0].rect.height().round()), (30.0, 20.0), "the ellipse's bounds");
app.run("shape.rectangle", json!({"x": 0, "y": 100, "width": 28_346.46, "height": 50})).unwrap();
app.run("file.exportAs", json!({"format": "pdf"})).unwrap();
confirm(&mut app).unwrap();
set(&mut app, "longPagesInPoints", json!(true));
frame(&mut app);
confirm(&mut app).unwrap();
let bytes = written.borrow()[1].1.clone();
assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit"));
let width = vectorcraft_pdf::import(&bytes).unwrap().artboards[0].rect.width();
assert!((28_346.0..28_350.0).contains(&width), "{width}");
}
#[test]
@@ -330,8 +353,17 @@ fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
};
// Each format whose options follow the panel (PDF's with Use Artboards: Save PDF).
let formats = [("png", false), ("svg", false), ("pdf", true), ("txt", false), ("dxf", false), ("eps", false), ("plugin.test.ui.held", false)];
// Each format whose options follow the panel (PDF's: Save PDF, with Use Artboards on or off).
let formats = [
("png", false),
("svg", false),
("pdf", true),
("pdf", false),
("txt", false),
("dxf", false),
("eps", false),
("plugin.test.ui.held", false),
];
for (format, artboards) in formats {
let (mut app, written) = app(1);
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&module)})).unwrap();
+13 -1
View File
@@ -62,6 +62,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
// Background Export: encoded and written from this snapshot while editing goes on. The
// encoder is told the file it writes (an exporter plug-in gets its name and extension).
let label = format!("Exporting {}", fileio::file_name(&path));
// A PDF's View PDF after saving: the first file written opens in the system viewer, unless
// automation asked for the export (checked now: a background export ends after the request).
let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false) && app.refuse_automation("viewAfterSaving").is_ok();
let (mut params, target) = (params.clone(), path.clone());
if let Some(o) = params.as_object_mut() {
o.insert("path".into(), json!(path));
@@ -113,6 +116,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
Some(first) => exported_with_notes(&what, warnings.len(), first),
None => format!("Exported {what}"),
};
if view && let Some(path) = r["path"].as_str() {
view_file(app, path);
}
show_with_notes(app, status, warnings);
Ok(r)
})?;
@@ -192,8 +198,14 @@ pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut par
/// File → Save as PDF: `document.exportPdf` with `params` (the Save PDF dialog's options), written
/// to `path` (else a picked or suggested name) → `{path, bytes, warnings}`. With
/// `viewAfterSaving`, the written file opens in the system viewer (not on the web, which
/// downloads it).
/// downloads it). Writing over the file the document was opened from, when opening it left things
/// out, asks first and runs `file.export.pdf` again on OK.
pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
if let Some(path) = params.get("path").and_then(Value::as_str)
&& let Some(r) = ask_before_losing(app, path, "file.export.pdf", &params)
{
return Ok(r);
}
let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false);
let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?;
if view {
+1 -1
View File
@@ -1243,7 +1243,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.savePdfDialog",
"Save PDF Dialog",
"",
"{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)",
"{path?, preset?, range?, useArtboards?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks); useArtboards: false: one page of the visible art's bounds, with no Artboards section",
),
(
"file.exportAs",
+10
View File
@@ -283,6 +283,16 @@ fn replacing_a_file_that_opening_left_things_out_of_asks_first() {
assert_eq!(app.run("file.exportAs", json!({"path": path, "format": "svg"})).unwrap()["pending"], dialogs::confirm::KIND);
app.ui.dialog = None;
assert_eq!(std::fs::read(&path).unwrap(), saved);
// So does a PDF written by Save PDF's OK, and the question stays open when Save PDF closes.
assert_eq!(app.run("file.export.pdf", json!({"path": path})).unwrap()["pending"], dialogs::confirm::KIND);
app.ui.dialog = None;
for artboards in [true, false] {
app.run("ui.savePdfDialog", json!({"path": path, "useArtboards": artboards})).unwrap();
dialogs::confirm(&mut app).unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND), "useArtboards {artboards}");
app.ui.dialog = None;
}
assert_eq!(std::fs::read(&path).unwrap(), saved);
// Another name doesn't ask.
let copy = d.join("b.svg").to_string_lossy().to_string();
assert!(app.run("file.exportAs", json!({"path": copy, "format": "svg"})).unwrap().get("pending").is_none());
+1 -1
View File
@@ -110,7 +110,7 @@ the two can still win that race.
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. After `app.save`, and `app.export` with a `path`, the status bar counts the reply's `warnings` and names the first, and with several, hovering it lists them in the reply's order (a save's notes start with what the format loses). With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `useArtboards: false` opens it for one page of the visible art's bounds, as Export As with Use Artboards off does: the dialog has no Artboards section and its `__artBounds` field is true. `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. A save or export that writes other files beside that path (a file per artboard, linked images, image maps) checks them before it writes anything: when any of them exists, it writes nothing and opens a `replaceFiles` dialog (fields `message`, `detail` (each file's name when there are several), `files`, `__command`, `__params`; the reply is `{"pending": "replaceFiles"}`, or comes once a Background Save or Export ends): `ui.dialog.confirm` runs the save or export again and replaces them, `ui.dialog.cancel` writes nothing; a dialog open when a background job asks stays open, and the question follows it. A save does not ask about the files the document's last Save or Save As in this session wrote. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab. Location refusals return `READ_ONLY_LOCATION: <reason>` without a fallback panel for automation; see [Save location errors](#save-location-errors). |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`, `.vcexportpresets`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
+3 -1
View File
@@ -884,7 +884,9 @@ Export As goes through `options` too: for PNG, JPEG and WebP `useArtboards: true
or `range`), named `<file>-<artboard>.<ext>`, and returns `{path, files}` (a PDF keeps them as pages of one file; an
SVG writes one file per artboard of a `range`); `useArtboards: false` exports the bounds of the visible art with what
its effects paint, the effects of groups, type and images included. `artboard.fitToArt` measures the art the same
way.
way. `document.exportPdf {useArtboards: false}` writes that one page as well, and ignores `artboard`, `artboards` and
`range`. Export As PDF continues in the Save PDF dialog with Use Artboards on or off; with it off, the dialog has no
Artboards section and the page is the bounds of the visible art.
Raster exports default to the document's raster effects resolution. An explicit `scale` sets pixels per
point (72 times that value in ppi); `options.ppi` wins over either scale option. CLI `run --export` and
+1 -1
View File
@@ -1513,7 +1513,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. The drawn art of these files strokes type in an envelope along its bent outlines at the stroke's own weight, Distort Appearance on or off, and VectorCraft draws it so (#6644): an envelope of stroked type whose grid squeezes or stretches it opens live where it draws as that art.
In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`).
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard.
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard. Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`).
PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked.
A PDF/A-2b export at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6 with a `UserUnit` and a warning, whether or not `longPagesInPoints` is on, so its page boundaries stay within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13) unless the page is too narrow for that (`crates/pdf/src/tests/marks.rs`).
PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`crates/engine/src/cmd/fileio/tests_pdfx.rs`).