M4.50: WebP options, Export As Text and Save for Office Documents
- WebP takes lossless and quality; there is no permissive pure-Rust lossy encoder yet, so
lossless: false writes lossless with a warning (the WebP Options dialog says so).
- document.export {format: "txt", encoding: utf8|utf16, lineEndings: lf|crlf,
selectionOnly}: the stories in stacking order (back to front), a thread once, hidden
objects and template layers left out; UTF-16 is little-endian with a byte order mark.
Export As → Text opens Text Export Options.
- File → Save for Office Documents… (document.exportForOffice {ppi, transparent, artboard,
path}): the artboard as a PNG at 72/150/300 ppi on white or transparent, through a small
dialog in the app.
document.formats' writable list and the MCP export tool's enum gain txt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ac77b52ad3
commit
da18cf4234
|
|
@ -151,6 +151,7 @@ struct RasterOptions {
|
|||
dither_amount: Option<u8>,
|
||||
transparency: Option<bool>,
|
||||
matte: Option<Value>,
|
||||
lossless: Option<bool>,
|
||||
}
|
||||
|
||||
impl RasterOptions {
|
||||
|
|
@ -309,6 +310,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
|||
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<Encoded> {
|
|||
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))),
|
||||
|
|
|
|||
|
|
@ -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<Value> {
|
|||
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<Value> {
|
|||
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<Cow<'a, Document>> {
|
||||
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<Arc<Node>> = 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<Value> {
|
||||
|
|
@ -69,19 +98,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Arc<Node>> = 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<Value> {
|
|||
}
|
||||
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<Value> {
|
||||
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) }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
"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 <stem>.html / <stem>.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 <stem>.html / <stem>.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<CommandSpec> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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<u16> = 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"][..]));
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
line_endings: Option<String>,
|
||||
}
|
||||
|
||||
/// The text file of `doc` with the text options of `p`.
|
||||
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Vec<u8>> {
|
||||
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<String> {
|
||||
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<NodeId>, out: &mut Vec<String>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
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::<Vec<_>>(), "description": "Default: from the path's extension"},
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
|||
}
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
||||
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<Value, String> {
|
||||
let path = io::save_command_output(app, "document.exportForOffice", "png", form::params(d))?;
|
||||
app.ui.dialog = None;
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
||||
let path = io::save_command_output(app, "document.export", "txt", form::params(d))?;
|
||||
app.ui.dialog = None;
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
|
@ -790,6 +790,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
Ok(Value::Null)
|
||||
}
|
||||
"ui.pdfPresetDialog" => 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<Item>)> {
|
|||
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"),
|
||||
|
|
|
|||
|
|
@ -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 `<stem>.html` (client-side `<map>`) or `<stem>.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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue