diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 8f18cc599..abda7258d 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -127,8 +127,8 @@ use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, - file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export, - writable_format, + file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, read_extensions, + readable_format, readable_format_for_name, titled_for_export, writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index d5d7a5ab9..adbc05c93 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -508,6 +508,7 @@ pub const OPEN_EXTS: &[&str] = &[ "ai", "ait", "png", + "pns", "jpg", "jpeg", "gif", @@ -515,6 +516,8 @@ pub const OPEN_EXTS: &[&str] = &[ "tif", "tiff", "bmp", + "dib", + "rle", "tga", "vda", "icb", @@ -527,6 +530,7 @@ pub const OPEN_EXTS: &[&str] = &[ "j2c", "jpc", "psd", + "pdd", "psb", "vctemplate", "dxf", @@ -542,10 +546,11 @@ pub const OPEN_EXTS: &[&str] = &[ /// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to /// raster editors, so Finder and file managers don't offer a vector app for them, and there is no -/// `.epsf`, `.ps`, Targa, PCX or JPEG 2000 type (File › Open and Place still read them all). On Linux the -/// desktop entry's EPS type (shared-mime-info's) also covers `.epsf`, so file managers offer -/// VectorCraft for it. -pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst", "pcx", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc"]; +/// `.epsf`, `.ps`, Targa, PCX or JPEG 2000 type, nor one for the `.pns`, `.dib` and `.rle` names of +/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS +/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it. +pub const UNASSOCIATED_EXTS: &[&str] = + &["psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc"]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares /// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order. @@ -586,6 +591,7 @@ pub const PLACE_EXTS: &[&str] = &[ "ai", "ait", "png", + "pns", "jpg", "jpeg", "gif", @@ -593,6 +599,8 @@ pub const PLACE_EXTS: &[&str] = &[ "tif", "tiff", "bmp", + "dib", + "rle", "tga", "vda", "icb", @@ -605,6 +613,7 @@ pub const PLACE_EXTS: &[&str] = &[ "j2c", "jpc", "psd", + "pdd", "psb", "vctemplate", "dxf", @@ -622,9 +631,32 @@ pub const PLACE_EXTS: &[&str] = &[ "doc", ]; +/// The extensions File › Open and Place read PNG, BMP and Photoshop files by: the format's own, +/// then the other names its files have (`.pns`, `.dib` and `.rle`, `.pdd`). Exports write only +/// the format's own extension. +const READ_ALIASES: &[(&str, &[&str])] = &[("png", &["png", "pns"]), ("bmp", &["bmp", "dib", "rle"]), ("psd", &["psd", "pdd"])]; + +/// The extensions File › Open and Place read format `f` by: its own, and for PNG, BMP and PSD +/// those of [`READ_ALIASES`]. +pub fn read_extensions(f: &Format) -> &'static [&'static str] { + READ_ALIASES.iter().find(|(id, _)| *id == f.id).map_or(f.extensions, |(_, exts)| *exts) +} + +/// The readable format of extension `ext` (any case, leading dot allowed): [`format`]'s when it +/// reads, else the one [`READ_ALIASES`] gives it (`.dib` is BMP). +pub fn readable_format(ext: &str) -> Option<&'static Format> { + let k = ext.trim_start_matches('.').to_ascii_lowercase(); + format(&k).filter(|f| f.read).or_else(|| READ_ALIASES.iter().find(|(_, exts)| exts.contains(&k.as_str())).and_then(|(id, _)| format(id))) +} + +/// The readable format a file name's extension names ([`readable_format`]). +pub fn readable_format_for_name(name: &str) -> Option<&'static Format> { + Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| readable_format(&e)) +} + /// One dialog filter per readable format. fn format_filters() -> impl Iterator { - FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions)) + FORMATS.iter().filter(|f| f.read).map(|f| (f.label, read_extensions(f))) } /// Open-dialog filters: "All readable files" first, then one per readable format, then swatch diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index c0976f6f8..b2a0eb71f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -9,7 +9,7 @@ use vectorcraft_render::effects::pixel::LoadedTexture; use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param}; use super::pdfimport::LoadOptions; -use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file}; +use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, psdread, read_file, readable_format_for_name}; use crate::cmd::links::Resolved; use crate::{EngineError, Prefs}; @@ -67,7 +67,7 @@ pub fn file_name(name: &str) -> String { /// The readable format of a file: from its content (magic bytes) when that tells, else from the /// extension of `name`. pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { - let by_name = format_for_name(name).filter(|f| f.read); + let by_name = readable_format_for_name(name); if vectorcraft_affinity::is_affinity(bytes) { return format("affinity"); } diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index e1964a97f..e8cd8e653 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -366,12 +366,12 @@ fn templates_open_untitled() { #[test] fn open_exts_cover_every_readable_format() { for f in FORMATS.iter().filter(|f| f.read) { - for e in f.extensions { + for e in read_extensions(f) { assert!(OPEN_EXTS.contains(e), "OPEN_EXTS lacks .{e} ({})", f.label); } } for e in OPEN_EXTS { - assert!(format(e).is_some_and(|f| f.read), ".{e} in OPEN_EXTS is no readable format"); + assert!(readable_format(e).is_some(), ".{e} in OPEN_EXTS is no readable format"); } let filters: Vec<_> = open_filters().collect(); assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); diff --git a/crates/engine/src/cmd/fileio/tests_psdread.rs b/crates/engine/src/cmd/fileio/tests_psdread.rs index 76ac9cee4..d411496b0 100644 --- a/crates/engine/src/cmd/fileio/tests_psdread.rs +++ b/crates/engine/src/cmd/fileio/tests_psdread.rs @@ -213,6 +213,28 @@ fn psd_exports_open_and_place_again() { assert!(PLACE_EXTS.contains(&"psd") && PLACE_EXTS.contains(&"psb") && OPEN_EXTS.contains(&"psb")); } +/// Photoshop files named `.pdd` open and place as PSD, in any case; the dialogs and a drop on the +/// window take them by their extension, and the desktop packaging doesn't associate them. +#[test] +fn pdd_files_open_and_place() { + let mut s = art(); + let f = exported(&mut s, json!({"format": "psd"})); + for name in ["art.pdd", "ART.PDD"] { + assert_eq!(readable_format_for_name(name).map(|f| f.id), Some("psd"), "{name}"); + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&f)})).unwrap(); + assert_eq!((&r["format"], &r["warnings"]), (&json!("psd"), &json!([])), "{name}: {r}"); + assert!((s.doc().unwrap().doc.artboards[0].rect.width() - 60.0).abs() < 1e-6, "{name}"); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let r = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&f)})).unwrap(); + assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("psd"), Some(1)), "{name}: {r}"); + } + assert!(OPEN_EXTS.contains(&"pdd") && PLACE_EXTS.contains(&"pdd") && UNASSOCIATED_EXTS.contains(&"pdd")); + let photoshop = ("PSD", &["psd", "pdd"][..]); + assert!(open_filters().any(|f| f == photoshop) && place_filters().any(|f| f == photoshop)); + assert!(!export_extensions().contains(&"pdd"), "exports still name their files .psd"); + assert!(format_for_name("x.pdd").is_none() && writable_format(None, Some("x.pdd")).is_err(), "an export to .pdd is refused as before"); +} + #[test] fn damaged_and_unsupported_files_are_refused_without_a_panic() { let good = psd(2, 4, (3, 2), 8, 3, &[], &[], &layer_count(-1, true), &raw(&[&[1; 6], &[2; 6], &[3; 6], &[4; 6]])); diff --git a/crates/engine/src/cmd/fileio/tests_tiffbmp.rs b/crates/engine/src/cmd/fileio/tests_tiffbmp.rs index b432fe57e..f36fa14b8 100644 --- a/crates/engine/src/cmd/fileio/tests_tiffbmp.rs +++ b/crates/engine/src/cmd/fileio/tests_tiffbmp.rs @@ -1,5 +1,6 @@ //! TIFF, BMP and Targa export through `document.export`: the options reach the files, impossible -//! ones are refused, and the files open again; Targa files open and place. +//! ones are refused, and the files open again; Targa files, and BMP and PNG files by their other +//! extensions, open and place. use serde_json::{Value, json}; @@ -203,6 +204,52 @@ fn targa_files_open_and_place() { assert!(open_filters().any(|f| f == targa) && place_filters().any(|f| f == targa)); } +/// BMP files named `.dib` or `.rle` (an RLE8 bitmap) and PNG files named `.pns` open and place as +/// BMP and PNG, in any case; the dialogs and a drop on the window take them by their extension, and +/// the desktop packaging doesn't associate them. +#[test] +fn dib_rle_and_pns_files_open_and_place() { + let mut s = session(); + let b64 = vectorcraft_format::base64_encode; + let bmp = export(&mut s, json!({"format": "bmp"})); + let rle = export(&mut s, json!({"format": "bmp", "depth": 8, "rle": true})); + assert_eq!(u32::from_le_bytes(rle[30..34].try_into().unwrap()), 1, "RLE8"); + let png = export(&mut s, json!({"format": "png"})); + for (name, bytes, id) in [ + ("art.dib", &bmp, "bmp"), + ("ART.DIB", &bmp, "bmp"), + ("art.rle", &rle, "bmp"), + ("Art.RLE", &rle, "bmp"), + ("art.pns", &png, "png"), + ("ART.PNS", &png, "png"), + ] { + assert_eq!(readable_format_for_name(name).map(|f| f.id), Some(id), "{name}"); + let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes)})).unwrap(); + assert_eq!((&r["format"], &r["warnings"]), (&json!(id), &json!([])), "{name}: {r}"); + assert_eq!(stored_pixels(&s).dimensions(), (60, 40), "{name}"); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let r = s.execute("file.place", &json!({"name": name, "dataBase64": b64(bytes)})).unwrap(); + assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!(id), Some(1)), "{name}: {r}"); + let black = stored_pixels(&s).get_pixel(40, 20).0; + assert!(black[3] == 255 && black[..3].iter().all(|c| *c < 80), "{name}: {black:?}"); + } + // The dialogs list the extensions; the desktop packaging doesn't associate them. + for e in ["dib", "rle", "pns"] { + assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}"); + } + let (bitmap, png) = (("BMP", &["bmp", "dib", "rle"][..]), ("PNG", &["png", "pns"][..])); + assert!(open_filters().any(|f| f == bitmap) && place_filters().any(|f| f == bitmap)); + assert!(open_filters().any(|f| f == png) && place_filters().any(|f| f == png)); + // Exports still write `.png` and `.bmp` only: an export to one of the other names is refused + // as before, and save panels don't offer them. + let named: Vec<&str> = export_extensions().into_iter().filter(|e| ["bmp", "dib", "rle", "png", "pns"].contains(e)).collect(); + assert_eq!(named, ["png", "bmp"]); + for name in ["x.dib", "x.rle", "x.pns"] { + assert!(format_for_name(name).is_none() && writable_format(None, Some(name)).is_err(), "{name}"); + assert!(s.execute("document.export", &json!({"path": name})).is_err(), "{name}"); + } +} + /// Targa pixels in every layout: run-length encoded rows stored top down (as the `image` crate /// writes them), a 16-bit file whose one attribute bit per pixel is its alpha, with runs, stored /// right to left and top down, 32-bit files with no attribute bits (opaque), with eight, and with diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 8662cb2b2..2e8d974d6 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -335,7 +335,8 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec BTreeMap, b fn format_label(d: &Document, im: &ImageObject) -> &'static str { im.link .as_ref() - .and_then(|l| fileio::format_for_name(l.name())) + .and_then(|l| fileio::format_for_name(l.name()).or_else(|| fileio::readable_format_for_name(l.name()))) .or_else(|| d.images.get(&im.key).and_then(|b| fileio::FORMATS.iter().find(|f| f.raster && f.mime == b.mime))) .map_or("Image", |f| f.label) } diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index 3d65a8c5b..41bc6b8fb 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -193,6 +193,34 @@ fn a_linked_targa_file_places_updates_and_relinks() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); } +/// A BMP file named `.dib` placed linked shows its pixels, is listed as BMP and updates when it +/// changes; relinked to a PNG named `.pns`, it is listed as PNG. +#[test] +fn linked_dib_and_pns_files_place_update_and_relink() { + let bmp = |w: u32, h: u32, rgb: [u8; 3]| { + let mut out = vec![]; + let [r, g, b] = rgb; + image::RgbImage::from_pixel(w, h, image::Rgb([r, g, b])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Bmp).unwrap(); + out + }; + let dir = Folder::new("dib"); + let pic = dir.file("art/scan.DIB"); + write(&pic, &bmp(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + assert_eq!(s.execute("links.info", &json!({"id": id.0})).unwrap()["format"], "BMP"); + write(&pic, &bmp(400, 400, BLUE)); + assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [id.0], "missing": []})); + assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); + let other = dir.file("other/red.pns"); + write(&other, &png(300, 100, RED)); + assert_eq!(s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap(), json!({"relinked": [id.0], "notFound": []})); + let im = image(&s, id); + assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other)); + assert_eq!(s.execute("links.info", &json!({"id": id.0})).unwrap()["format"], "PNG"); +} + #[test] fn the_relative_path_finds_the_file_after_the_folder_moves() { let a = Folder::new("move-a"); diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 486f969cf..cc506b182 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -92,7 +92,7 @@ fn export_tool_lists_engine_formats_and_options() { let open = tools.iter().find(|t| t["name"] == "open_file").unwrap(); assert!(open["description"].as_str().unwrap().contains(".ait")); assert!(open["description"].as_str().unwrap().contains(".eps, .epsf, .ps, "), "{open}"); - assert!(open["description"].as_str().unwrap().contains(".bmp, .tga, .vda, .icb, .vst, "), "{open}"); + assert!(open["description"].as_str().unwrap().contains(".bmp, .dib, .rle, .tga, .vda, .icb, .vst, "), "{open}"); } /// A fake app that records `engine.execute` calls and answers each with an empty object. diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 1b96fd072..1e1ad72fc 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -294,7 +294,7 @@ fn texturizer_textures(loaded: bool) -> form::Choices { /// it and picked in the Texture menu. fn load_texture(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { use vectorcraft_engine::cmd::fileio; - let filters = crate::FilePick::filters(fileio::FORMATS.iter().filter(|f| f.read && f.raster).map(|f| (f.label, f.extensions))); + let filters = crate::FilePick::filters(fileio::FORMATS.iter().filter(|f| f.read && f.raster).map(|f| (f.label, fileio::read_extensions(f)))); let path = crate::picks::open(app, &crate::FilePick { filters, ..Default::default() }).ok_or("cancelled")?; // The button is off where files can't be read. let Some(read) = app.services.read.as_ref() else { return Ok(()) }; diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 80196fcd9..9e751cf1f 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -238,7 +238,8 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Re pub(crate) fn openable_original(path: &str) -> Result<(), String> { let readable = |p: &std::path::Path| { let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - !ext.is_empty() && vectorcraft_engine::cmd::fileio::FORMATS.iter().any(|f| f.read && f.extensions.contains(&ext.as_str())) + use vectorcraft_engine::cmd::fileio::{FORMATS, read_extensions}; + !ext.is_empty() && FORMATS.iter().any(|f| f.read && read_extensions(f).contains(&ext.as_str())) }; let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()); let real = std::fs::canonicalize(path).map_err(|e| format!("can't open “{name}”: {e}"))?; diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 5dd1c47a6..98e1dd250 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -172,7 +172,19 @@ fn edit_original_and_show_in_folder_hand_the_file_to_the_system() { #[test] fn edit_original_opens_image_and_document_files_only() { let dir = Folder::new("kinds"); - for f in ["photo.png", "Art.SVG", "run.exe", "run.bat", "photo.png.lnk", "page.hta", "app.desktop"] { + for f in [ + "photo.png", + "Art.SVG", + "scan.dib", + "photo.PDD", + "run.exe", + "run.bat", + "photo.png.lnk", + "page.hta", + "app.desktop", + "art.affinity", + "scan.jpeg2000", + ] { dir.file(f, b""); } for f in ["Tool.app", "folder.png"] { @@ -180,7 +192,21 @@ fn edit_original_opens_image_and_document_files_only() { } let ok = |f: &str| super::links::openable_original(&dir.0.join(f).to_string_lossy()).is_ok(); assert!(ok("photo.png") && ok("Art.SVG")); - for f in ["run.exe", "run.bat", "photo.png.lnk", "page.hta", "app.desktop", "Tool.app", "folder.png", "missing.png", ""] { + assert!(ok("scan.dib") && ok("photo.PDD"), "BMP and Photoshop files by their other names"); + // A format's id is not one of its extensions. + for f in [ + "run.exe", + "run.bat", + "photo.png.lnk", + "page.hta", + "app.desktop", + "art.affinity", + "scan.jpeg2000", + "Tool.app", + "folder.png", + "missing.png", + "", + ] { assert!(!ok(f), "{f}"); } #[cfg(unix)] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4a348d7d..cdb22fb65 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -138,9 +138,9 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | -| PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | +| PSD | open, place, export | yes (merged image, every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | | PSB | open | yes (merged image) | no | | -| PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | +| PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | HEIC / HEIF | open, place | no | no | | | PCX | open, place | yes (1 bit in the header palette's first two colors, black and white without them; 1 bit in two to four planes and 4 bits with up to 16 colors; 2 bits in the CGA colors the header's CGA palette settings pick; 8 bits with the 256-color palette, gray without one; 24-bit color in three planes) | no | the header's HDpi and VDpi size the image; a file of another depth or plane count, cut short or past the pixel allowance is refused with a message; tests in `crates/engine/src/cmd/fileio/tests_pcxread.rs` and `crates/engine/tests/import_fuzz.rs` | diff --git a/docs/mcp.md b/docs/mcp.md index c53eafb12..5802a8144 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -427,7 +427,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | +| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), and `preview: true` embeds a PNG of the first artboard. `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version` or `preview` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version` or `preview`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version` or `preview`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d69100339..66add4773 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1116,6 +1116,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`). EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow or Black, are also written in process colors with a warning (`crates/eps/src/tests.rs`). A spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4), is written as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients and DSC comments; Registration still prints on every plate (`tests::spot_inks_named_all_or_none_are_written_under_names_of_their_own`). - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). + - BMP files named `.dib` or `.rle` (run-length encoded bitmaps included), Photoshop files named `.pdd` and PNG files named `.pns` open and place, linked or embedded, as BMP, PSD and PNG files, in any letter case: the Open and Place dialogs list them with their format, the Links panel names their format, and Edit Original opens them. Exports write only `.bmp`, `.psd` and `.png` (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/cmd/fileio/tests_psdread.rs`, `crates/engine/src/tests/links.rs`). - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).