diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 5ada012c..4848b37d 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -151,6 +151,7 @@ struct RasterOptions { dither_amount: Option, transparency: Option, matte: Option, + lossless: Option, } impl RasterOptions { @@ -309,6 +310,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { d.template = true; vectorcraft_format::save_file(&d) } + "txt" => super::text::encode(doc, p)?, "svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; @@ -357,6 +359,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { if enc.files.is_empty() { return Err(bad(C, "the document has no artboard")); } + if format == RasterFormat::WebP && o.lossless == Some(false) { + enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); + } return Ok(enc); } _ => return Err(bad(C, format!("no encoder for {} yet", f.label))), diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 7974df5b..7f8c91bc 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -1,11 +1,12 @@ //! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`. +use std::borrow::Cow; use std::collections::HashSet; use std::sync::Arc; use serde::Deserialize; use serde_json::{Value, json}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind}; use super::super::*; use super::encode::single_artboard; @@ -18,7 +19,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?; let expanded = super::pdf::expand_preset(s, "document.serialize", p)?; let p = &*expanded; - let doc = &s.doc()?.doc; + let doc = &*source(s, f, p, "document.serialize")?; let enc = encode_all(doc, f.id, p)?; let files = enc.named(doc, &default_name(doc, f.extensions[0])); let (main, linked) = files.split_at(enc.files.len()); @@ -42,7 +43,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { let f = writable("document.export", str_param(p, "format"), path)?; let expanded = super::pdf::expand_preset(s, "document.export", p)?; let p = &*expanded; - let doc = &s.doc()?.doc; + let doc = &*source(s, f, p, "document.export")?; let enc = encode_all(doc, f.id, p)?; write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) } @@ -61,6 +62,34 @@ pub(super) fn without_artboards(p: &Value) -> Value { q } +/// The document an export of `f` writes: the active one, or for text with `selectionOnly` the +/// selected objects alone. +fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result> { + if f.id == "txt" && bool_or(p, "selectionOnly", false) { + return Ok(Cow::Owned(selection(s, cmd)?.0)); + } + Ok(Cow::Borrowed(&s.doc()?.doc)) +} + +/// The selected objects (not on template layers) alone on one layer, with one artboard: their +/// visual bounds. +fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> { + let ids = edit::selected_roots(s)?; + let st = s.doc()?; + let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); + // On a template layer or sublayer, at any depth. + let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); + let nodes: Vec> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect(); + let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(cmd, "select something to export"))?; + let mut d = single_artboard(&st.doc, bounds, "Selection"); + let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + *ch = nodes; + } + d.layers = vec![Arc::new(layer)]; + Ok((d, bounds)) +} + /// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on /// template layers are guides, not artwork, and are left out. pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { @@ -69,19 +98,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { let f = writable(C, str_param(p, "format"), path)?; let expanded = super::pdf::expand_preset(s, C, p)?; let p = &*expanded; - let ids = edit::selected_roots(s)?; - let st = s.doc()?; - let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); - // On a template layer or sublayer, at any depth. - let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); - let nodes: Vec> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect(); - let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?; - let mut d = single_artboard(&st.doc, bounds, "Selection"); - let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); - if let Some(ch) = layer.children_mut() { - *ch = nodes; - } - d.layers = vec![Arc::new(layer)]; + let (d, bounds) = selection(s, C)?; let bytes = encode(&d, f.id, &without_artboards(p))?; write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] })) } @@ -179,3 +196,24 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { } Ok(json!({ "files": files })) } + +/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless +/// `transparent`. +pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result { + const C: &str = "document.exportForOffice"; + let path = str_param(p, "path"); + let ppi = f64_or(p, "ppi", 150.0); + let doc = &s.doc()?.doc; + let artboard = match p.get("artboard") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(C, "artboard is a 0-based index"))?, + }; + if artboard >= doc.artboards.len() { + return Err(bad(C, format!("no artboard {artboard} (0-based; the document has {})", doc.artboards.len()))); + } + let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" }; + let bytes = encode(doc, "png", &json!({ "ppi": ppi, "background": background, "artboard": artboard }))?; + // The pixel size, from the PNG header. + let side = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map_or(0, u32::from_be_bytes); + write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20) })) +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 79aab41c..1ec7fcbf 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -24,6 +24,7 @@ pub mod pngtext; pub mod ppi; mod save; mod svg; +mod text; use serde_json::{Value, json}; @@ -55,7 +56,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -64,7 +65,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -105,6 +106,15 @@ pub fn specs() -> Vec { always, pdfimport::pdf_info ), + cmd!( + "document.exportForOffice", + "Save for Office Documents…", + ["File"], + None, + "{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0} the artboard as a PNG for office documents and slides → {path, bytes, width, height} (pixels); no path → {dataBase64, bytes, width, height}", + has_doc, + export::export_for_office + ), ] .into_iter() .chain(save::specs()) @@ -263,6 +273,14 @@ const MATTE: FormatOption = FormatOption { default: "\"white\"", description: "the colour partly transparent edges are blended over (\"#rrggbb\", white, black), or none (they keep their colour)", }; +const LOSSLESS: FormatOption = FormatOption { + name: "lossless", + ty: "boolean", + default: "true", + description: "lossless WebP; false asks for lossy, which isn't available yet (written lossless, with a warning)", +}; +const WEBP_QUALITY: FormatOption = + FormatOption { name: "quality", ty: "integer", default: "90", description: "lossy quality 0–100 (for when lossy WebP is available)" }; /// The options of the palette formats (PNG-8, GIF). const PALETTE_OPTIONS: &[FormatOption] = &[ ARTBOARD, @@ -357,7 +375,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY], }, reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true), reader("bmp", "BMP", &["bmp"], "image/bmp", true), @@ -372,6 +390,7 @@ pub const FORMATS: &[Format] = &[ options: &[], }, Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS }, + Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS }, ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -623,3 +642,6 @@ mod tests_jpeg; #[cfg(test)] mod tests_palette; + +#[cfg(test)] +mod tests_text; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 013179dc..53f9cf15 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() { let mut s = Session::new(); let r = s.execute("document.formats", &json!({})).unwrap(); let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); - assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8"]); + assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt"]); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); assert_eq!(png["options"]["scale"]["default"], 1); diff --git a/crates/engine/src/cmd/fileio/tests_text.rs b/crates/engine/src/cmd/fileio/tests_text.rs new file mode 100644 index 00000000..9a41aead --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_text.rs @@ -0,0 +1,87 @@ +//! Export As Text, WebP options and Save for Office Documents. + +use serde_json::{Value, json}; + +use super::*; + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +fn text(s: &mut Session, x: f64, y: f64, t: &str) -> u64 { + s.execute("text.create", &json!({"x": x, "y": y, "text": t})).unwrap()["id"].as_u64().unwrap() +} + +#[test] +fn text_export_writes_the_stories_in_stacking_order() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + text(&mut s, 10.0, 200.0, "Back story\nsecond line"); + let front = text(&mut s, 10.0, 40.0, "Front"); + // A thread of two frames is one story, written once. + let a = + s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Threaded words", "area": {"width": 120, "height": 40}})).unwrap()["id"].clone(); + let b = s.execute("text.create", &json!({"x": 200, "y": 80, "text": "", "area": {"width": 120, "height": 40}})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + // Hidden objects are left out. + let hidden = text(&mut s, 10.0, 260.0, "Hidden"); + s.execute("select.set", &json!({"ids": [hidden]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + + let export = |s: &mut Session, p: Value| b64(&s.execute("document.export", &merge(json!({"format": "txt"}), p)).unwrap()); + let utf8 = String::from_utf8(export(&mut s, json!({}))).unwrap(); + assert_eq!(utf8, "Back story\nsecond line\nFront\nThreaded words\n", "back to front, one line ending after each line"); + let crlf = export(&mut s, json!({"lineEndings": "crlf"})); + assert_eq!(String::from_utf8(crlf).unwrap(), utf8.replace('\n', "\r\n")); + let utf16 = export(&mut s, json!({"encoding": "utf16"})); + assert_eq!(&utf16[..2], [0xFF, 0xFE], "byte order mark"); + let units: Vec = utf16[2..].chunks(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); + assert_eq!(String::from_utf16(&units).unwrap(), utf8); + // Only the selection. + s.execute("select.set", &json!({"ids": [front]})).unwrap(); + assert_eq!(export(&mut s, json!({"selectionOnly": true})), b"Front\n"); + for bad in [json!({"encoding": "latin1"}), json!({"lineEndings": "cr"})] { + assert!(s.execute("document.export", &merge(json!({"format": "txt"}), bad.clone())).is_err(), "{bad}"); + } + // A .txt path picks the format. + let dir = std::env::temp_dir().join(format!("vc-text-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("story.txt"); + assert_eq!(s.execute("document.export", &json!({"path": path.to_string_lossy()})).unwrap()["format"], "txt"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), utf8); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn webp_is_lossless_and_says_so_when_lossy_is_asked_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 30, "height": 20})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + let r = s.execute("document.export", &json!({"format": "webp"})).unwrap(); + let file = b64(&r); + assert_eq!((&file[..4], &file[8..12], &file[12..16]), (&b"RIFF"[..], &b"WEBP"[..], &b"VP8L"[..]), "lossless bitstream"); + assert_eq!(r["warnings"], json!([])); + let r = s.execute("document.export", &json!({"format": "webp", "lossless": false, "quality": 60, "background": "black"})).unwrap(); + assert_eq!(&b64(&r)[12..16], b"VP8L"); + assert!(r["warnings"][0].as_str().unwrap().contains("lossless"), "{r}"); + assert_eq!(image::load_from_memory(&b64(&r)).unwrap().to_rgba8().get_pixel(29, 19).0, [0, 0, 0, 255]); +} + +#[test] +fn save_for_office_writes_a_png_at_the_resolution() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "artboards": 2})).unwrap(); + let r = s.execute("document.exportForOffice", &json!({"ppi": 150})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(1275), Some(1650)), "Letter at 150 ppi"); + let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (1275, 1650)); + assert_eq!(img.get_pixel(0, 0).0, [255, 255, 255, 255], "on white"); + let r = s.execute("document.exportForOffice", &json!({"ppi": 72, "transparent": true, "artboard": 1})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(612), Some(792))); + assert_eq!(image::load_from_memory(&b64(&r)).unwrap().to_rgba8().get_pixel(0, 0)[3], 0, "transparent"); + assert!(s.execute("document.exportForOffice", &json!({"artboard": 2})).is_err()); + assert!(s.execute("document.exportForOffice", &json!({"ppi": -3})).is_err()); + let spec = crate::cmd::find_command("document.exportForOffice").unwrap(); + assert_eq!((spec.label, spec.menu), ("Save for Office Documents…", &["File"][..])); +} diff --git a/crates/engine/src/cmd/fileio/text.rs b/crates/engine/src/cmd/fileio/text.rs new file mode 100644 index 00000000..7e01a775 --- /dev/null +++ b/crates/engine/src/cmd/fileio/text.rs @@ -0,0 +1,96 @@ +//! Export As Text (`.txt`): the document's stories, one after another in stacking order (back to +//! front), as UTF-8 or UTF-16 with LF or CRLF line endings. Threaded frames are one story, +//! written where its first frame is; hidden objects and template layers are left out. + +use std::collections::HashSet; + +use serde::Deserialize; +use serde_json::Value; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; + +use super::super::*; +use super::FormatOption; + +const C: &str = "document.export"; + +const ENCODING: FormatOption = FormatOption { + name: "encoding", + ty: "string", + default: "\"utf8\"", + description: "utf8 (no byte order mark) or utf16 (little-endian, with a byte order mark)", +}; +const LINE_ENDINGS: FormatOption = + FormatOption { name: "lineEndings", ty: "string", default: "\"lf\"", description: "lf (macOS, Linux) or crlf (Windows)" }; +const SELECTION_ONLY: FormatOption = + FormatOption { name: "selectionOnly", ty: "boolean", default: "false", description: "only the selected type objects (document.export)" }; + +/// The options `document.formats` lists for text. +pub(super) const OPTIONS: &[FormatOption] = &[ENCODING, LINE_ENDINGS, SELECTION_ONLY]; + +#[derive(Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct TextOptions { + encoding: Option, + line_endings: Option, +} + +/// The text file of `doc` with the text options of `p`. +pub(super) fn encode(doc: &Document, p: &Value) -> Result> { + let o = if p.is_object() { TextOptions::deserialize(p).map_err(|e| bad(C, format!("Text options: {e}")))? } else { TextOptions::default() }; + let eol = match o.line_endings.as_deref().map(str::to_ascii_lowercase).as_deref() { + None | Some("lf") => "\n", + Some("crlf") => "\r\n", + Some(other) => return Err(bad(C, format!("lineEndings `{other}`: lf or crlf"))), + }; + let mut text = String::new(); + for story in stories(doc) { + // Any line break in the story becomes the chosen one; every story ends with one. + let story = story.replace("\r\n", "\n").replace('\r', "\n"); + for line in story.split('\n') { + text.push_str(line); + text.push_str(eol); + } + } + match o.encoding.as_deref().map(|e| e.to_ascii_lowercase().replace(['-', '_'], "")).as_deref() { + None | Some("utf8") => Ok(text.into_bytes()), + Some("utf16" | "utf16le" | "unicode") => Ok([0xFF, 0xFE].into_iter().chain(text.encode_utf16().flat_map(u16::to_le_bytes)).collect()), + Some(_) => Err(bad(C, format!("encoding `{}`: utf8 or utf16", o.encoding.unwrap_or_default()))), + } +} + +/// The text of every visible story in stacking order (back to front). +fn stories(doc: &Document) -> Vec { + let mut out = vec![]; + let mut done = HashSet::new(); + for l in &doc.layers { + collect(doc, l, &mut done, &mut out); + } + out +} + +fn collect(doc: &Document, n: &Node, done: &mut HashSet, out: &mut Vec) { + if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) { + return; + } + if let NodeKind::Text(t) = &n.kind { + if done.contains(&n.id) { + return; + } + // A threaded frame: the whole thread's story, once. + match doc.text_threads.iter().find(|th| th.contains(&n.id)) { + Some(thread) => { + done.extend(thread.iter().copied()); + let frames = thread.iter().filter_map(|id| doc.node(*id)).filter_map(|f| match &f.kind { + NodeKind::Text(t) => Some(t.plain_text()), + _ => None, + }); + out.push(frames.collect()); + } + None => out.push(t.plain_text()), + } + return; + } + for c in n.children().into_iter().flatten() { + collect(doc, c, done, out); + } +} diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 26f5297b..0512d3ce 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() { let tools = tool_definitions(); let export = tools.iter().find(|t| t["name"] == "export").unwrap(); let props = &export["inputSchema"]["properties"]; - assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8"])); + assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt"])); for k in ["artboard", "range", "options"] { assert!(props.get(k).is_some(), "export takes {k}"); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 04daf393..f954a2d4 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8 (one rendered artboard; png8 is an indexed .png) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", + "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8 (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", obj( json!({ "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 20695e14..2b452b01 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -1,14 +1,14 @@ //! File → Export → Export As…: the format and Use Artboards (All or a range: one file per //! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK -//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP Options, SVG Options, -//! and Save PDF for artboards as pages), else the file is written. +//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG +//! Options, Text Export Options, and Save PDF for artboards as pages), else the file is written. use std::sync::LazyLock; use serde_json::{Value, json}; use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format}; -use super::{DialogSpec, form, png_options, save_pdf, svg_options}; +use super::{DialogSpec, form, png_options, save_pdf, svg_options, text_export}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, io, widgets}; @@ -109,6 +109,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } // Artboards as pages go on in Save PDF; the art's bounds are written straight away. "pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })), + "txt" => { + text_export::open(app, &path); + Ok(Value::Null) + } _ => { let mut params = json!({ "format": f.id, "path": path, "useArtboards": use_artboards }); if use_artboards { diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 50d224d2..b6c64ac7 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -31,6 +31,7 @@ pub mod missing_links; pub mod new_color_group; mod new_document; pub mod new_swatch; +pub mod office_export; mod path_ops; pub mod pdf_presets; pub mod place; @@ -48,6 +49,7 @@ pub mod spot_colors; pub(crate) mod svg_options; pub mod swatch_conflict; pub mod swatch_options; +mod text_export; pub mod text_import; pub mod tile_edge_color; mod tools; @@ -203,6 +205,8 @@ registry! { PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC, PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC, SaveOptions: [save_options::KIND] => save_options::SPEC, + TextExport: [text_export::KIND] => text_export::SPEC, + OfficeExport: [office_export::KIND] => office_export::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/office_export.rs b/crates/ui-egui/src/dialogs/office_export.rs new file mode 100644 index 00000000..cd8e1678 --- /dev/null +++ b/crates/ui-egui/src/dialogs/office_export.rs @@ -0,0 +1,64 @@ +//! File → Save for Office Documents…: an artboard as a PNG for office documents and slides, at +//! 72, 150 or 300 ppi, on white or transparent. Fields are `document.exportForOffice` params. + +use serde_json::{Value, json}; + +use super::png_options::{RESOLUTION_LABELS, RESOLUTIONS}; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, io, widgets}; + +pub(crate) const KIND: &str = "saveForOffice"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Save for Office Documents".into(), body, confirm, ok: Some("Save…"), min_width: 340.0, ..DialogSpec::FORM }; + +/// Open the dialog (150 ppi on white, the first artboard). +pub fn open(app: &mut VectorcraftApp) -> Result { + app.session.active().ok_or("no document")?; + app.ui.dialog = Some(Dialog::new(KIND, json!({"ppi": RESOLUTIONS[1], "transparent": false, "artboard": 0}))); + Ok(Value::Null) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim)); + let Some(doc) = app.session.active().map(|st| &st.doc) else { return true }; + let ppi = d.f64("ppi", RESOLUTIONS[1]); + let board = (d.f64("artboard", 0.0).max(0.0) as usize).min(doc.artboards.len().saturating_sub(1)); + egui::Grid::new("office-export").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + label(ui, "Resolution:"); + let at = RESOLUTIONS.iter().position(|r| *r == ppi).unwrap_or(1); + if let Some(i) = widgets::dropdown(ui, "office-ppi", RESOLUTION_LABELS[at], &RESOLUTION_LABELS[..RESOLUTIONS.len()], 160.0) + && let Some(r) = RESOLUTIONS.get(i) + { + d.fields.insert("ppi".into(), json!(r)); + } + ui.end_row(); + if doc.artboards.len() > 1 { + label(ui, "Artboard:"); + let names: Vec<&str> = doc.artboards.iter().map(|a| a.name.as_str()).collect(); + if let Some(i) = widgets::dropdown(ui, "office-artboard", names.get(board).copied().unwrap_or_default(), &names, 160.0) { + d.fields.insert("artboard".into(), json!(i)); + } + ui.end_row(); + } + if let Some(a) = doc.artboards.get(board) { + label(ui, "Size:"); + let (w, h) = vectorcraft_render::region_pixels(a.rect, ppi / 72.0); + ui.label(egui::RichText::new(format!("{w} × {h} px")).color(t.text)); + ui.end_row(); + } + }); + ui.add_space(8.0); + form::check(ui, d, "transparent", "Transparent Background"); + false +} + +/// Pick the file and write it (the command runs first: bad options keep the dialog open). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let path = io::save_command_output(app, "document.exportForOffice", "png", form::params(d))?; + app.ui.dialog = None; + Ok(json!({ "path": path })) +} diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 0b28e0ad..c457b98b 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -19,9 +19,9 @@ use crate::{VectorcraftApp, io, widgets}; pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, ok: Some("Export"), min_width: 360.0, ..DialogSpec::FORM }; /// Resolution presets (pixels per inch). -const RESOLUTIONS: [f64; 3] = [72.0, 150.0, 300.0]; +pub(super) const RESOLUTIONS: [f64; 3] = [72.0, 150.0, 300.0]; /// Their labels, then `Other` (any resolution). -const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "High (300 ppi)", "Other"]; +pub(super) const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "High (300 ppi)", "Other"]; /// Background param values. const BACKGROUNDS: [&str; 3] = ["transparent", "white", "black"]; /// Their labels, then `Other` (a colour). @@ -171,6 +171,11 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } "jpg" => jpeg_rows(ui, d, &label), "gif" | "png8" => palette_rows(ui, d, &label), + "webp" => { + ui.label(""); + label(ui, "Lossless (lossy WebP isn't available yet)"); + ui.end_row(); + } _ => {} } @@ -279,7 +284,7 @@ fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, } /// A colour button bound to `d.fields[key]` (`"#rrggbb"`; grey when unset). -fn color_button(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { +pub(super) fn color_button(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { let c = vectorcraft_color::Color::from_hex(&d.str(key)).map_or([128, 128, 128, 255], |c| c.to_rgba8(1.0)); let mut rgb = [c[0], c[1], c[2]]; if ui.color_edit_button_srgb(&mut rgb).changed() { @@ -288,7 +293,7 @@ fn color_button(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { } /// A dropdown bound to `d.fields[key]`: one of `ids`, shown by its label (the first when unknown). -fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], labels: &[&str]) { +pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], labels: &[&str]) { let cur = d.str(key); let at = ids.iter().position(|v| v.eq_ignore_ascii_case(&cur)).unwrap_or(0); if let Some(i) = widgets::dropdown(ui, ("ro", key), labels.get(at).copied().unwrap_or_default(), labels, 150.0) diff --git a/crates/ui-egui/src/dialogs/tests_raster_formats.rs b/crates/ui-egui/src/dialogs/tests_raster_formats.rs index e4a73adc..dcef576a 100644 --- a/crates/ui-egui/src/dialogs/tests_raster_formats.rs +++ b/crates/ui-egui/src/dialogs/tests_raster_formats.rs @@ -1,4 +1,5 @@ -//! The options of the raster formats beyond PNG, drawn headlessly: JPEG, PNG-8 and GIF. +//! The options of the export formats beyond PNG, drawn headlessly: JPEG, PNG-8, GIF and WebP, Text +//! Export Options and Save for Office Documents. use serde_json::json; @@ -77,3 +78,58 @@ fn palette_options_reduce_the_colours() { assert_eq!(path, "/out/Untitled-1.png"); assert_eq!(png[25], 3, "indexed"); } + +#[test] +fn text_export_options_write_the_stories() { + let (mut app, written) = app(1); + app.run("text.create", json!({"x": 5, "y": 30, "text": "Hello\nthere"})).unwrap(); + app.run("file.exportAs", json!({"format": "txt"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("txtOptions")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("path"), d.str("encoding")), ("/out/Untitled-1.txt".into(), "utf8".into())); + frame(&mut app); + set(&mut app, "lineEndings", json!("crlf")); + set(&mut app, "encoding", json!("bogus")); + assert!(confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some("txtOptions"), "a bad option keeps the dialog open"); + set(&mut app, "encoding", json!("utf8")); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["path"], "/out/Untitled-1.txt"); + assert!(app.ui.dialog.is_none()); + let (path, bytes) = written.borrow().last().cloned().unwrap(); + assert_eq!((path.as_str(), bytes.as_slice()), ("/out/Untitled-1.txt", &b"Hello\r\nthere\r\n"[..])); +} + +#[test] +fn webp_options_say_the_file_is_lossless() { + let (mut app, _) = app(1); + app.run("file.exportAs", json!({"format": "webp"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("webpOptions")); + frame(&mut app); +} + +#[test] +fn save_for_office_documents_picks_the_resolution() { + let (mut app, written) = app(2); + let item = crate::menus::menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some("document.exportForOffice")).unwrap(); + assert_eq!((item.path[0].as_str(), item.label.as_str(), item.enabled), ("File", "Save for Office Documents…", true)); + app.run("document.exportForOffice", json!({})).unwrap(); + assert_eq!(kind(&app), Some("saveForOffice")); + frame(&mut app); + set(&mut app, "ppi", json!(300)); + set(&mut app, "artboard", json!(1)); + set(&mut app, "transparent", json!(true)); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["path"], "/out/Untitled-1.png"); + let img = image::load_from_memory(&written.borrow()[0].1).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (250, 167), "60 × 40 pt at 300 ppi"); + assert_eq!(img.get_pixel(0, 0)[3], 0, "transparent"); + // With a path (agents), no dialog. + let r = app.run("document.exportForOffice", json!({"path": "/x/o.png", "ppi": 72})).unwrap(); + assert_eq!(r["path"], "/x/o.png"); + assert!(app.ui.dialog.is_none()); + let img = image::load_from_memory(&written.borrow()[1].1).unwrap().to_rgba8(); + assert_eq!((img.dimensions(), img.get_pixel(0, 0).0), ((60, 40), [255, 255, 255, 255])); +} diff --git a/crates/ui-egui/src/dialogs/text_export.rs b/crates/ui-egui/src/dialogs/text_export.rs new file mode 100644 index 00000000..748942c7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/text_export.rs @@ -0,0 +1,52 @@ +//! Export As → Text: the Text Export Options (encoding, line endings, selection only) for a +//! picked `.txt` path. Fields are `document.export` params (`format`, `path`…). + +use serde_json::{Value, json}; + +use super::png_options::choice; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, io}; + +pub(crate) const KIND: &str = "txtOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Text Export Options".into(), body, confirm, ok: Some("Export"), min_width: 340.0, ..DialogSpec::FORM }; + +const ENCODINGS: [&str; 2] = ["utf8", "utf16"]; +const ENCODING_LABELS: [&str; 2] = ["UTF-8", "UTF-16 (Unicode)"]; +const LINE_ENDINGS: [&str; 2] = ["lf", "crlf"]; +const LINE_ENDING_LABELS: [&str; 2] = ["LF (macOS, Linux)", "CRLF (Windows)"]; + +/// Open the options for exporting the document's text to `path`. +pub fn open(app: &mut VectorcraftApp, path: &str) { + // The platform's own line endings by default. + let eol = if cfg!(windows) { LINE_ENDINGS[1] } else { LINE_ENDINGS[0] }; + let fields = json!({"format": "txt", "path": path, "encoding": ENCODINGS[0], "lineEndings": eol, "selectionOnly": false}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim)); + egui::Grid::new("text-export").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + label(ui, "Encoding:"); + choice(ui, d, "encoding", &ENCODINGS, &ENCODING_LABELS); + ui.end_row(); + label(ui, "Line Endings:"); + choice(ui, d, "lineEndings", &LINE_ENDINGS, &LINE_ENDING_LABELS); + ui.end_row(); + }); + ui.add_space(8.0); + let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + ui.add_enabled_ui(selected, |ui| form::check(ui, d, "selectionOnly", "Selection Only")); + false +} + +/// Write the text file (the command runs first, so a bad option keeps the dialog open). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let path = io::save_command_output(app, "document.export", "txt", form::params(d))?; + app.ui.dialog = None; + Ok(json!({ "path": path })) +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2ed6ee41..b29976dd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -790,6 +790,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_pdf_preset(app, p), + // Save for Office Documents…: its dialog; with options, pick the file (or write `path`). + "document.exportForOffice" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::office_export::open(app), + "document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })), _ => return None, }; Some(r) @@ -1057,6 +1060,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Save a Copy…", "file.saveCopy"), c("Save as Template…", "file.saveAsTemplate"), c("Save as PDF…", "file.export.pdf"), + c("Save for Office Documents…", "document.exportForOffice"), c("Revert", "file.revert"), Sep, c("Place…", "file.place"), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ac7184e5..f71ea1cb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -28,6 +28,8 @@ | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | | `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `.html` (client-side ``) or `.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) | | `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields | +| `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning | +| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | diff --git a/docs/mcp.md b/docs/mcp.md index 3e0dd2f6..2a20832c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -64,7 +64,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. | | `save_file` | `{path?}` | 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). | -| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `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. | +| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `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?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |