diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index dd6bbb7fe..5be1a0b13 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -20,6 +20,9 @@ const C: &str = "document.export"; /// Most pixels along a side of a preview. const MAX_PREVIEW: f64 = 2048.0; +/// Save needs a lightweight compatibility preview, not an uncompressed 16 MiB image beside +/// a small vector. TIFF format choices are unchanged; there is no public resolution option. +const SAVE_PREVIEW: f64 = 1024.0; /// Most pixels along a side of a thumbnail. const THUMBNAIL: f64 = 128.0; @@ -260,7 +263,20 @@ fn native(doc: &Document, board: Option, include_linked: bool) -> Vec /// artboard (after a central file of the whole document when there are several and art shows). /// Transparency is flattened first (once for all of them); the previews show `doc` itself. pub(super) fn encode(doc: &Document, p: &Value) -> Result { + encode_document(doc, p, false) +} + +/// Save keeps a single page's original frame and all native editing data, unlike Export's +/// optional art-bound crop or separate file per artboard. Existing EPS loss warnings still apply. +pub(super) fn encode_save(doc: &Document, p: &Value) -> Result { + encode_document(doc, p, true) +} + +fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { let set = parse(p)?; + if saving && (set.use_artboards == Some(false) || bool_or(p, "selectedOnly", false)) { + return Err(bad(C, "EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS")); + } let original = doc; let (full, warnings) = crate::cmd::place::document::full_documents(doc); let doc = &*full; @@ -272,7 +288,13 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { } else { Cow::Borrowed(original) }; - let pages = pages(doc, &set)?; + let pages = if saving { + let board = set.boards.one(doc.artboards.len()).map_err(|e| bad(C, e))?; + let rect = doc.artboards.get(board).ok_or_else(|| bad(C, "the document has no artboard"))?.rect; + vec![(Some(board), rect, Point::new(rect.x0, rect.y1))] + } else { + pages(doc, &set)? + }; if pages.is_empty() { return Err(bad(C, "the document has no artboard")); } @@ -284,13 +306,14 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { if transparent && flat.is_some() { enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into()); } + let preview_limit = if saving { SAVE_PREVIEW } else { MAX_PREVIEW }; let created = vectorcraft_doc::metadata::output_time(); let mut renderer = vectorcraft_render::Renderer::new(); for (board, region, origin) in pages { let rect = vectorcraft_eps::preview_rect(region, origin); let preview = match set.eps.preview { Preview::None => None, - kind => Some(render(&mut renderer, doc, rect, MAX_PREVIEW, set.eps.transparent_preview && kind == Preview::TiffColor)?), + kind => Some(render(&mut renderer, doc, rect, preview_limit, set.eps.transparent_preview && kind == Preview::TiffColor)?), }; let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?; let o = EpsOptions { @@ -298,7 +321,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { origin, title: doc.title.clone(), created, - native: Some(native(&editing, board, set.include_linked)), + native: Some(native(&editing, if saving { None } else { board }, set.include_linked)), ..set.eps.clone() }; let out = vectorcraft_eps::export(drawn, &o, preview.as_ref(), thumbnail.as_ref()).map_err(|e| bad(C, e))?; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 59e784073..ecb1c3099 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -430,8 +430,19 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st /// `opts` are the options it was read with. fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs); - // What reading the file left out of the document, if anything. - let losses: Vec = warnings.iter().filter(|w| is_loss(w)).cloned().collect(); + // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing + // fallbacks must ask before overwriting the richer source they could not fully read. + let losses: Vec = warnings + .iter() + .filter(|w| { + is_loss(w) + || (format.id == "eps" + && (matches!(w.as_str(), EDITING_DAMAGED | EDITING_STALE) + || w.starts_with("this file's PostScript couldn't be read") + || w.starts_with("the file was read up to an error"))) + }) + .cloned() + .collect(); let source_path = path.clone().filter(|_| !losses.is_empty()); // A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled // document. diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 7bd605b70..da1a4f5ac 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -21,7 +21,7 @@ use crate::{DocState, Prefs}; /// The formats Save As offers, in menu order (append-only). The other writable formats are exports: /// they never become the document's own file. -pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]; +pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai", "eps"]; /// The formats Save as Template writes, the default first: a native template, or a /// PDF-compatible `.ait` (written as a `.ai` file is). @@ -431,9 +431,9 @@ pub fn reads_option(f: &Format, key: &str) -> bool { } /// Formats that carry the whole native document: they lose nothing, and record links relative to -/// where they are written. A `.ai` or `.ait` file is a PDF carrying it. +/// where they are written. A `.ai` or `.ait` file is a PDF carrying it; EPS carries it in comments. fn is_native(f: &Format) -> bool { - matches!(f.id, "vectorcraft" | "template" | "ai" | "ait") + matches!(f.id, "vectorcraft" | "template" | "ai" | "ait" | "eps") } fn is_pdf(f: &Format) -> bool { @@ -520,7 +520,7 @@ impl std::io::Write for Counted { /// `separateArtboards`, those `range` names (default all). fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { // A legacy .ai file is a file per artboard already. - if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, f, p)? { + if !is_native(f) || f.id == "eps" || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, f, p)? { return Ok(vec![]); } let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string); @@ -735,8 +735,12 @@ impl SaveJob { /// Encode one file of `doc` in the job's format. fn encode_one(&self, doc: &Document) -> Result { let (cmd, f) = (self.plan.mode.command(), self.plan.format); - let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params) } else { encode_all(doc, f.id, &self.params) } - .map_err(|e| named_for_save(cmd, e))?; + let enc = match f.id { + "ai" | "ait" => encode_ai(cmd, f, doc, &self.params), + "eps" => super::eps::encode_save(doc, &self.params), + _ => encode_all(doc, f.id, &self.params), + } + .map_err(|e| named_for_save(cmd, e))?; // A legacy .ai file per artboard. let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?; if enc.files.len() != 1 && !per_artboard { @@ -950,7 +954,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), @@ -959,7 +963,7 @@ pub(super) fn specs() -> Vec { "Save As…", ["File"], Some("Cmd+Shift+S"), - "{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}", + "{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}", has_doc, |s, p| save(s, SaveMode::SaveAs, p) ), diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index a24ee0f2f..e4828375e 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -84,7 +84,7 @@ fn save_as_takes_on_path_format_and_options() { let svg = s.execute("file.formatOptions", &json!({"format": "svg"})).unwrap(); assert_eq!(svg["options"]["outlineText"]["value"], false, "another format shows its defaults"); let ids: Vec<&str> = o["saveFormats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]); + assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai", "eps"]); // SVGZ is gzip-compressed SVG and reopens as SVGZ. let svgz = path(&d, "doc.svgz"); s.execute("file.saveAs", &json!({"path": svgz})).unwrap(); @@ -405,3 +405,141 @@ fn transparency_grid_round_trips_without_dirtying() { assert!(!s.doc().unwrap().transparency_grid); let _ = std::fs::remove_dir_all(d); } + +#[test] +fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { + let d = dir("eps-original-format"); + let eps_path = path(&d, "Artwork.eps"); + // Original, contributor-authored PostScript: the artwork occupies only part of the page. + let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n0 0 1 setrgbcolor 30 20 40 30 rectfill\nshowpage\n%%EOF\n"; + std::fs::write(&eps_path, source).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": eps_path})).unwrap(); + assert_eq!(s.doc().unwrap().format, "eps"); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); + let board = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((board.width(), board.height()), (200.0, 100.0)); + rect(&mut s); + let edited_layers = s.doc().unwrap().doc.layers.clone(); + let r = s.execute("document.save", &json!({"options": {"previewFormat": "none", "thumbnails": false}})).unwrap(); + assert_eq!((r["path"].as_str(), r["format"].as_str()), (Some(eps_path.as_str()), Some("eps"))); + let bytes = std::fs::read(&eps_path).unwrap(); + assert!(bytes.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0")); + assert!(std::str::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0).unwrap().contains("%%BoundingBox: 0 0 200 100")); + let editing = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(editing.artboards[0].rect, board); + assert_eq!(editing.layers, edited_layers); + assert!(!s.doc().unwrap().is_dirty()); + let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap(); + assert_eq!(opened["restored"], true); + assert_eq!(s.doc().unwrap().doc.layers, edited_layers); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect, board); + rect(&mut s); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn eps_save_keeps_all_editing_artboards_while_printing_one_page() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let r = s.execute("document.save", &json!({"format": "eps", "artboard": 1, "previewFormat": "none", "thumbnails": false})).unwrap(); + let bytes = b64(&r); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.artboards.len(), 2); + assert_eq!(native.layers, s.doc().unwrap().doc.layers); + assert!(s.execute("document.save", &json!({"format": "eps", "range": "all"})).is_err(), "EPS is one printed page"); + for options in [json!({"useArtboards": false}), json!({"selectedOnly": true})] { + assert!(s.execute("document.save", &json!({"format": "eps", "options": options})).is_err(), "cropping is Export's job"); + } +} + +#[test] +fn eps_save_retains_lossy_import_overwrite_protection_and_transparency_warning() { + let d = dir("eps-import-warning"); + let eps_path = path(&d, "Artwork.eps"); + let mut s = session(); + rect(&mut s); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + let r = s.execute("document.save", &json!({"path": eps_path, "previewFormat": "none", "thumbnails": false})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flatten")), "{r}"); + let original = std::fs::read(&eps_path).unwrap(); + let st = s.doc_mut().unwrap(); + st.imported_from = Some(eps_path.clone()); + st.import_losses.push("test import omitted artwork".into()); + rect(&mut s); + assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss")); + assert_eq!(std::fs::read(&eps_path).unwrap(), original); + s.execute("document.save", &json!({"acknowledgeLoss": true})).unwrap(); + assert_ne!(std::fs::read(&eps_path).unwrap(), original); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn eps_save_supports_an_empty_artboard_without_cropping_its_page() { + let mut s = session(); + let r = s.execute("document.save", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap(); + let bytes = b64(&r); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.artboards[0].rect, s.doc().unwrap().doc.artboards[0].rect); + assert_eq!(native.node_count(), s.doc().unwrap().doc.node_count()); + assert!(std::str::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0).unwrap().contains("%%BoundingBox: 0 0 200 100")); +} + +#[test] +fn partially_read_eps_requires_confirmation_before_save_back() { + let d = dir("eps-partial-import"); + let eps_path = path(&d, "partial.eps"); + let original = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n0 0 1 setrgbcolor 30 20 40 30 rectfill\nunsupportedMockyOperator\n1 0 0 setrgbcolor 80 20 40 30 rectfill\nshowpage\n%%EOF\n"; + std::fs::write(&eps_path, original).unwrap(); + let mut s = Session::new(); + let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap(); + assert!(opened["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().starts_with("the file was read up to an error")), "{opened}"); + rect(&mut s); + assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss")); + assert_eq!(std::fs::read(&eps_path).unwrap(), original); + s.execute("document.save", &json!({"acknowledgeLoss": true})).unwrap(); + assert!(vectorcraft_eps::native(&std::fs::read(&eps_path).unwrap()).is_some()); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn eps_with_damaged_editing_data_requires_confirmation_before_save_back() { + let d = dir("eps-damaged-editing"); + let eps_path = path(&d, "damaged.eps"); + let mut s = session(); + rect(&mut s); + let r = s.execute("document.exportEps", &json!({"previewFormat": "none", "thumbnails": false, "cmykPostScript": false})).unwrap(); + let mut bytes = b64(&r); + let at = bytes.windows(9).position(|w| w == b"native\n% ").unwrap() + 12; + bytes[at] = b'z'; + std::fs::write(&eps_path, &bytes).unwrap(); + let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap(); + assert_eq!(opened["restored"], false); + assert!(opened["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("editing data can't be read")), "{opened}"); + rect(&mut s); + assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss")); + assert_eq!(std::fs::read(&eps_path).unwrap(), bytes); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn eps_save_bounds_large_page_preview_without_cropping_vector_or_editing_data() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 4000, "height": 4000})).unwrap(); + rect(&mut s); + // Explicit colour preview remains a colour preview; Save bounds its resolution, not the art. + let r = s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap(); + let bytes = b64(&r); + let (ps, tiff) = vectorcraft_eps::sections(&bytes).unwrap(); + let tiff = tiff.unwrap(); + assert!(tiff.len() < 4 * 1024 * 1024 + 64 * 1024, "compatibility preview grew to {} bytes", tiff.len()); + let preview = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).unwrap(); + assert_eq!((preview.width(), preview.height()), (1024, 1024)); + assert!(std::str::from_utf8(ps).unwrap().contains("%%BoundingBox: 0 0 4000 4000")); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.artboards, s.doc().unwrap().doc.artboards); + assert_eq!(native.layers, s.doc().unwrap().doc.layers); +} diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index b320897de..c05ff231b 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -87,6 +87,9 @@ fn shown(f: &Format) -> impl Iterator { || (range && o.name == "artboard") || (separate && o.name == "range") || (matches!(f.id, "ai" | "ait") && o.name == "useArtboards") + // EPS Save keeps a page and the complete editing document; cropped and + // selected-only output belongs to Export, never this save dialog. + || (f.id == "eps" && matches!(o.name, "useArtboards" | "selectedOnly")) || (f.id == "ait" && o.name == "aiVersion")) }) } diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 9c8ae60bb..fe46331a5 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -72,7 +72,7 @@ fn save_as_svg_opens_svg_options_then_writes_svg() { // The save panel offers every save format, the document's own first. let pick = &picks.borrow()[0]; let labels: Vec<&str> = pick.filters.iter().map(|f| f.0.as_str()).collect(); - assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]); + assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai", "EPS"]); assert_eq!(pick.name, "Untitled-1.vectorcraft"); let d = dialog(&app); assert_eq!((d.kind.as_str(), d.str("path").as_str(), d.str("mode").as_str()), ("svgOptions", "art.svg", "save")); @@ -332,3 +332,18 @@ fn save_as_pdf_asks_with_the_save_pdf_dialog_and_remembers_its_options() { assert_eq!(dialog(&app).str("compatibility"), "1.5"); assert_eq!(dialog(&app).str("__save"), "file.saveCopy"); } + +#[test] +fn eps_save_as_keeps_page_options_and_writes_eps_without_export_only_switches() { + let (mut app, written, _) = desktop("art.eps"); + app.run("file.saveAs", json!({})).unwrap(); + let names: Vec<_> = shown(fileio::format("eps").unwrap()).map(|option| option.name).collect(); + assert!(!names.contains(&"useArtboards") && !names.contains(&"selectedOnly")); + assert!(names.contains(&"previewFormat")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let saved = written.borrow(); + assert_eq!(saved[0].0, "art.eps"); + assert!(vectorcraft_eps::native(&saved[0].1).is_some()); + assert_eq!(app.session.active().unwrap().format, "eps"); +} diff --git a/crates/ui-egui/src/tests/save.rs b/crates/ui-egui/src/tests/save.rs index 985dabf2e..472f564eb 100644 --- a/crates/ui-egui/src/tests/save.rs +++ b/crates/ui-egui/src/tests/save.rs @@ -303,3 +303,22 @@ fn native_saves_stream_into_the_file() { assert_eq!(*streamed.lock().unwrap(), [native]); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn file_save_writes_opened_eps_back_without_another_save_as_dialog() { + let d = dir("eps-original-format"); + let path = d.join("Artwork.eps").to_string_lossy().to_string(); + std::fs::write(&path, b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n0 0 1 setrgbcolor 30 20 40 30 rectfill\nshowpage\n%%EOF\n").unwrap(); + let mut app = app(); + app.run("document.open", json!({"path":path})).unwrap(); + rect(&mut app); + app.run("file.save", json!({})).unwrap(); + crate::background::wait_all(&mut app); + assert!(app.ui.dialog.is_none(), "ordinary EPS Save does not require another format dialog"); + let bytes = std::fs::read(&path).unwrap(); + assert!(vectorcraft_eps::sections(&bytes).unwrap().0.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0")); + assert!(vectorcraft_eps::native(&bytes).is_some()); + assert_eq!(app.session.active().unwrap().format, "eps"); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(path.as_str())); + std::fs::remove_dir_all(d).unwrap(); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index aca1574a1..4277b5cb0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -183,3 +183,30 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 2026-10-11 | minor | `.ai` in PostScript form opens through its layers, also without its prolog (#1027) | | 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) | | 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures | + +## EPS Save and source protection + +Feature evidence updated 2026-10-26; scores and estimates unchanged. + +EPS is offered by Save As, and an opened EPS keeps its own path for ordinary Save. Save writes +one artboard (the first by default, or the named artboard), retaining its page bounds and transparent +margins rather than cropping to the drawn artwork. The whole editable VectorCraft document remains +embedded in EPS comments, including other artboards. Save bounds the optional TIFF preview to +1024 pixels on its longest side (about 4 MiB of RGBA pixels), avoiding the roughly 16 MiB preview a +large artboard otherwise produces. Export keeps its existing 2048-pixel preview cap. Preview +format choices are unchanged; no public preview-resolution option exists. The EPS PostScript appearance still flattens +transparency; its existing warnings remain. This does not promise native Illustrator editing-data fidelity. + +Cropped and selected-only EPS output remains available through Export. Save refuses those options. +An EPS opened from a preview, a partially interpreted program or damaged native editing data keeps +its source-loss warning: writing over that original requires explicit confirmation. File › Save uses +the same engine path and write-failure handling as other document formats. + +Synthetic regressions: `tests_save::opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins`, +`eps_save_keeps_all_editing_artboards_while_printing_one_page`, +`eps_save_supports_an_empty_artboard_without_cropping_its_page`, +`partially_read_eps_requires_confirmation_before_save_back`, +`eps_with_damaged_editing_data_requires_confirmation_before_save_back`, +`eps_save_bounds_large_page_preview_without_cropping_vector_or_editing_data`, and the UI regressions +`file_save_writes_opened_eps_back_without_another_save_as_dialog` and +`eps_save_as_keeps_page_options_and_writes_eps_without_export_only_switches`. diff --git a/docs/gaps.md b/docs/gaps.md index c6047bc3b..4f4e46b1d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -152,6 +152,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users +- **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. + - **Done (2026-10-16):** EPS legacy mesh corners and vector opacity masks retained; winding-declared compound member groups keep shared paint. Raster print-page minification no longer causes a false editing-data fallback; comparison limits remain unchanged. Synthetic regressions cover damage and missing artwork. This narrows G9; scores and estimates unchanged. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead @@ -835,3 +837,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | + +## EPS Save evidence (2026-10-26) + +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7428e6f9d..7cde74b20 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -85,7 +85,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish EPS ordinary Save retains the page frame and editable document (2026-10-26); scores and estimates unchanged. | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | @@ -286,3 +286,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | + +## EPS Save evidence (2026-10-26) + +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index db4fdd061..db39fd9a2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -136,7 +136,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and Arrange | 9–14 | estimated; document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions) | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions) EPS ordinary Save retains the page frame and editable document (2026-10-26); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and Arrange | 9–14 | estimated; document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and Arrange | 9–14 | estimated | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; other tool and drag cases remain), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | @@ -1432,3 +1432,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi | 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | + +## EPS Save evidence (2026-10-26) + +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged.