mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
Merge pull request #6938 from dotCooCoo/fix/pdf-large-page-export
Export As: a PDF without artboards opens Save PDF and its options
This commit is contained in:
@@ -408,15 +408,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
|
||||
@@ -596,7 +612,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);
|
||||
@@ -606,13 +622,17 @@ 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);
|
||||
// A PDF of the art's bounds carries no editing data (it would replace the artboards).
|
||||
if use_artboards == Some(false)
|
||||
&& f.id == "pdf"
|
||||
&& let Some((bytes, notes)) = super::pdf::encode_art_page(C, doc, p)?
|
||||
{
|
||||
return Ok(Encoded::one_with_notes(bytes, notes));
|
||||
}
|
||||
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
@@ -56,6 +56,126 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF of the art's bounds carries no Preserve Editing data, through `document.exportPdf`,
|
||||
/// `document.export` and the Summary's export in memory, and says so in a warning: reopened, its
|
||||
/// editing data would replace the document's artboards with the one around the art. A PDF of the
|
||||
/// artboards keeps the data, and it reopens with every artboard. With Preserve Editing off, there
|
||||
/// is no warning.
|
||||
#[test]
|
||||
fn a_pdf_of_the_arts_bounds_carries_no_editing_data() {
|
||||
let mut s = session(3);
|
||||
let left_out = |v: &Value| warnings(v).iter().any(|w| w == super::pdf::EDITING_NEEDS_THE_ARTBOARDS);
|
||||
let art = json!({"useArtboards": false});
|
||||
for v in [export(&mut s, art.clone()), s.execute("document.export", &as_export(&art)).unwrap()] {
|
||||
let pdf = b64(&v);
|
||||
assert!(vectorcraft_pdf::editing(&pdf).is_none(), "{:?}", warnings(&v));
|
||||
assert!(left_out(&v), "{:?}", warnings(&v));
|
||||
assert_eq!(pages(&pdf), 1);
|
||||
}
|
||||
let summary = s.execute("document.pdfSettings", &json!({"includeDocument": true, "useArtboards": false})).unwrap();
|
||||
assert!(left_out(&summary), "{summary}");
|
||||
let quiet = export(&mut s, json!({"useArtboards": false, "preserveEditing": false}));
|
||||
assert!(!left_out(&quiet), "{:?}", warnings(&quiet));
|
||||
|
||||
for v in [export(&mut s, json!({})), export(&mut s, json!({"useArtboards": true}))] {
|
||||
let editing = vectorcraft_pdf::editing(&b64(&v)).expect("editing data");
|
||||
let reopened = vectorcraft_format::load(&editing.data).unwrap();
|
||||
assert_eq!(reopened.artboards.len(), 3);
|
||||
assert!(!left_out(&v), "{:?}", warnings(&v));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -1248,7 +1248,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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,8 +81,13 @@ 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 art_bounds = match params.get("useArtboards") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => !b,
|
||||
Some(v) => return Err(format!("useArtboards must be true or false, not {v}")),
|
||||
};
|
||||
let mut fields = settings_fields(app, params)?;
|
||||
let s = |k: &str| params.get(k).and_then(Value::as_str);
|
||||
fields.insert("preset".into(), json!(s("preset").unwrap_or(pdf::DEFAULT_PRESET)));
|
||||
@@ -88,6 +98,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 art_bounds {
|
||||
fields.insert(ART_BOUNDS.into(), json!(true));
|
||||
}
|
||||
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -117,10 +130,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 +154,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 +517,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,112 @@ 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"));
|
||||
}
|
||||
|
||||
/// `useArtboards` other than true, false or null is refused, as `document.exportPdf` refuses it,
|
||||
/// and no dialog opens.
|
||||
#[test]
|
||||
fn a_use_artboards_that_is_not_true_or_false_is_refused() {
|
||||
let (mut app, _, _) = app();
|
||||
for bad in [json!("false"), json!(0), json!({})] {
|
||||
let e = app.run("ui.savePdfDialog", json!({"path": "/tmp/x.pdf", "useArtboards": bad})).unwrap_err();
|
||||
assert!(e.contains("useArtboards must be true or false"), "{bad}: {e}");
|
||||
assert!(app.ui.dialog.is_none(), "{bad}: no dialog opens");
|
||||
}
|
||||
for ok in [json!(true), json!(null)] {
|
||||
app.run("ui.savePdfDialog", json!({"path": "/tmp/x.pdf", "useArtboards": ok})).unwrap();
|
||||
assert!(!app.ui.dialog.take().unwrap().bool(ART_BOUNDS), "{ok}: the artboards' dialog");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// A document without a title of its own, saved to a file, is titled after that file in the PDF,
|
||||
/// and viewers show the file name, with or without Use Artboards; one with its own title shows
|
||||
/// that title.
|
||||
#[test]
|
||||
fn a_page_of_the_arts_bounds_shows_the_title_only_when_it_is_the_documents_own() {
|
||||
let (mut app, written, _) = app();
|
||||
app.session.doc_mut().unwrap().path = Some("/docs/Poster.vectorcraft".into());
|
||||
for artboards in [true, false] {
|
||||
app.run("ui.savePdfDialog", json!({"path": "/tmp/t.pdf", "useArtboards": artboards, "compression": {"compressText": false}})).unwrap();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
}
|
||||
std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc).title = "Spring Sale".into();
|
||||
app.run("ui.savePdfDialog", json!({"path": "/tmp/t.pdf", "useArtboards": false, "compression": {"compressText": false}})).unwrap();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let w = written.borrow();
|
||||
assert_eq!(w.len(), 3);
|
||||
let texts: Vec<String> = w.iter().map(|(_, b)| String::from_utf8_lossy(b).into_owned()).collect();
|
||||
for (pdf, artboards) in texts[..2].iter().zip([true, false]) {
|
||||
assert!(pdf.contains("/Title(Poster)"), "useArtboards {artboards}");
|
||||
assert!(!pdf.contains("/DisplayDocTitle true"), "useArtboards {artboards}");
|
||||
}
|
||||
assert!(texts[2].contains("/Title(Spring Sale)") && texts[2].contains("/DisplayDocTitle true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_reach_the_status_and_errors_keep_the_dialog_open() {
|
||||
let (mut app, written, opened) = app();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -44,6 +44,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
|
||||
std::borrow::Cow::Borrowed(_) => st.doc.clone(),
|
||||
std::borrow::Cow::Owned(d) => std::sync::Arc::new(d),
|
||||
};
|
||||
// A PDF shows its title in the viewer's title bar only when it is the document's own, not the
|
||||
// file name it was titled after, as `document.export` writes it.
|
||||
let own_title = fileio::has_own_title(&doc);
|
||||
let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new);
|
||||
let uid = st.uid;
|
||||
let path = export_path(app, path, f)?;
|
||||
@@ -62,9 +65,16 @@ 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, also through a save panel it opened (checked now: a
|
||||
// background export ends after the request).
|
||||
let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false) && !app.automation_asked();
|
||||
let (mut params, target) = (params.clone(), path.clone());
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
if f.id == "pdf" {
|
||||
o.insert("_displayDocTitle".into(), json!(own_title));
|
||||
}
|
||||
}
|
||||
// The progress dialog's counts and Cancel: a cancelled export stops drawing and never starts
|
||||
// writing.
|
||||
@@ -113,6 +123,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,10 +205,30 @@ 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).
|
||||
pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
|
||||
/// downloads it). Writing over the file the document was opened from, when opening it left things
|
||||
/// out, asks first, for a given path and for one the save panel picked, and runs
|
||||
/// `file.export.pdf` again on OK.
|
||||
pub fn export_pdf(app: &mut VectorcraftApp, mut 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", ¶ms)
|
||||
{
|
||||
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)?;
|
||||
let given = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string));
|
||||
let mut v = app.session.execute("document.exportPdf", ¶ms).map_err(|e| e.to_string())?;
|
||||
let bytes = take_output(&mut v)?;
|
||||
let path = match given {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let picked = target_path(app, None, "pdf")?;
|
||||
if let Some(r) = ask_before_losing(app, &picked, "file.export.pdf", ¶ms) {
|
||||
return Ok(r);
|
||||
}
|
||||
picked
|
||||
}
|
||||
};
|
||||
write_reporting(app, &path, &bytes)?;
|
||||
if view {
|
||||
view_file(app, &path);
|
||||
}
|
||||
|
||||
@@ -472,8 +472,9 @@ pub fn save_pdf(app: &mut VectorcraftApp, mode: SaveMode, mut params: Value) ->
|
||||
|
||||
/// Open a written file in the system viewer (not on the web, which downloads it).
|
||||
pub(super) fn view_file(app: &mut VectorcraftApp, path: &str) {
|
||||
// The viewer is another app: an export automation asked for is written but not opened.
|
||||
if !is_web(app) && app.refuse_automation("viewAfterSaving").is_ok() {
|
||||
// The viewer is another app: an export automation asked for is written but not opened, also
|
||||
// when it goes on from a save panel automation opened.
|
||||
if !is_web(app) && !app.automation_asked() {
|
||||
app.open_url(&file_url(path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1249,7 +1249,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",
|
||||
|
||||
@@ -210,6 +210,38 @@ fn save_as_goes_on_with_the_path_picked() {
|
||||
assert_eq!(*written.borrow(), ["/art/poster.svg"]);
|
||||
}
|
||||
|
||||
/// Save as PDF with View PDF after Saving, asked for by automation without a path: the save panel
|
||||
/// it opened answers as automation's, so the PDF is written but not opened in the viewer, even
|
||||
/// when a person picked the file. The person's own Save as PDF opens it.
|
||||
#[test]
|
||||
fn a_pdf_automation_asked_for_is_not_opened_when_its_save_panel_answers() {
|
||||
let opened = Rc::new(RefCell::new(Vec::<String>::new()));
|
||||
let u = opened.clone();
|
||||
let mut services =
|
||||
Services { dialogs_are_sheets: true, open_url: Some(Box::new(move |p: &str| u.borrow_mut().push(p.to_string()))), ..Default::default() };
|
||||
let (shown, written) = off_the_ui_thread(&mut services);
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
|
||||
let view = json!({"viewAfterSaving": true});
|
||||
|
||||
app.ui.answering_control = true;
|
||||
let _ = app.run("file.export.pdf", view.clone());
|
||||
app.ui.answering_control = false;
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)]));
|
||||
shown.answer(&["/art/automated.pdf"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(*written.borrow(), ["/art/automated.pdf"]);
|
||||
assert!(opened.borrow().is_empty(), "{:?}", opened.borrow());
|
||||
|
||||
let _ = app.run("file.export.pdf", view);
|
||||
shown.answer(&["/art/mine.pdf"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(*written.borrow(), ["/art/automated.pdf", "/art/mine.pdf"]);
|
||||
assert_eq!(opened.borrow().len(), 1, "{:?}", opened.borrow());
|
||||
assert!(opened.borrow()[0].ends_with("/art/mine.pdf"), "{:?}", opened.borrow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialogs_folder_button_fills_its_field_when_the_picker_answers() {
|
||||
let (mut app, shown, _) = app();
|
||||
|
||||
@@ -283,6 +283,33 @@ 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);
|
||||
// Also when File › Save as PDF… leaves the file to the save panel and the panel picks the PDF
|
||||
// the document was opened from.
|
||||
let pdf = d.join("a.pdf").to_string_lossy().to_string();
|
||||
std::fs::write(&pdf, b"%PDF-1.7 as opened").unwrap();
|
||||
app.session.doc_mut().unwrap().imported_from = Some(pdf.clone());
|
||||
let picked = pdf.clone();
|
||||
app.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
|
||||
for artboards in [true, false] {
|
||||
app.run("ui.savePdfDialog", json!({"useArtboards": artboards})).unwrap();
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
let asked = app.ui.dialog.as_ref().filter(|d| d.kind == dialogs::confirm::KIND);
|
||||
assert!(asked.is_some_and(|d| d.str("detail").contains("“hidden type”")), "picked, useArtboards {artboards}");
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
assert_eq!(std::fs::read(&pdf).unwrap(), b"%PDF-1.7 as opened");
|
||||
app.services.pick_save = None;
|
||||
app.session.doc_mut().unwrap().imported_from = Some(path.clone());
|
||||
// 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());
|
||||
|
||||
@@ -108,9 +108,9 @@ the two can still win that race.
|
||||
| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
|
||||
| `ui.resize` / `ui.focus` | | |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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. With `useArtboards: false` it writes the page as `app.export` does and returns `{path, warnings}`, or `{background: true, status}` while Background Export writes it |
|
||||
| `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 |
|
||||
|
||||
+5
-1
@@ -955,7 +955,11 @@ 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`. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it
|
||||
is in VectorCraft Default), the export comes back with a warning that says so.
|
||||
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
|
||||
|
||||
@@ -1541,6 +1541,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, 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.
|
||||
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`).
|
||||
|
||||
Reference in New Issue
Block a user