mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M4.38: native and .ai save options: each artboard to its own file, linked files, ICC profiles, PDF content
- document.save / Save As / Save a Copy to a native or .ai file take separateArtboards + range (also write <name>-<artboard>.<ext> per artboard beside the master file, holding that artboard and the art touching it; the result lists every file), includeLinked (linked images keep their files' own pixels), embedProfiles (ICC profiles loaded from files that the document is tagged with; opening installs them where missing) and pdfCompatible (native: also carry a PDF of every artboard; .ai: false writes blank pages around the native document). .ai files also take compress (compression.compressText). - File > Save As to a native or .ai file shows the options dialog (VectorCraft Options) after the save panel: Save each artboard to a separate file (All or a range), Include Linked Files, Embed ICC Profiles, Create PDF-Compatible File, Use Compression, Preview; the web's Save As dialog shows them too. Save, Save a Copy and Save as Template reuse the options as last saved. - Format: optional "profiles" and "pdf" members (load_file, pdf_content), SaveOptions.include_linked/profiles/pdf; files without them are unchanged. - Changed test: tests_aisave::save_as_ai_writes_a_pdf_that_reopens_editable now confirms the options dialog Save As .ai shows before writing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
975067b44a
commit
bf4614669e
19 files changed
+883
-72
No files matched your search
@@ -85,8 +85,9 @@ impl Encoded {
|
||||
}
|
||||
|
||||
/// Every file to write for the destination `path` (a path or a file name): the file itself
|
||||
/// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images
|
||||
/// beside it under their own names, then the image maps as `{image stem}.html` or `.map`.
|
||||
/// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard
|
||||
/// among several, as Save's master file, is the file itself), then the linked images beside it
|
||||
/// under their own names, then the image maps as `{image stem}.html` or `.map`.
|
||||
pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, Cow<'a, [u8]>)> {
|
||||
// Siblings keep the path's own separators (this runs on every platform and on the web).
|
||||
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
|
||||
@@ -94,15 +95,19 @@ impl Encoded {
|
||||
let mut out: Vec<(String, Cow<[u8]>)> = match self.files.as_slice() {
|
||||
[(_, bytes)] => vec![(path.to_string(), Cow::Borrowed(bytes.as_slice()))],
|
||||
files => {
|
||||
let boards: Vec<usize> = files.iter().map(|(b, _)| b.unwrap_or(0)).collect();
|
||||
let boards: Vec<usize> = files.iter().filter_map(|(b, _)| *b).collect();
|
||||
let file = std::path::Path::new(&path[dir.len()..]);
|
||||
let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let ext = file.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
|
||||
super::artboard_file_names(doc, &boards)
|
||||
.into_iter()
|
||||
.zip(files)
|
||||
.map(|(board, (_, bytes))| {
|
||||
(sibling(&format!("{stem}{}{board}{ext}", self.joiner.unwrap_or("-"))), Cow::Borrowed(bytes.as_slice()))
|
||||
let mut names = super::artboard_file_names(doc, &boards).into_iter();
|
||||
files
|
||||
.iter()
|
||||
.map(|(b, bytes)| {
|
||||
let name = match b {
|
||||
None => path.to_string(),
|
||||
Some(_) => sibling(&format!("{stem}{}{}{ext}", self.joiner.unwrap_or("-"), names.next().unwrap_or_default())),
|
||||
};
|
||||
(name, Cow::Borrowed(bytes.as_slice()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ fn restore_or_import(
|
||||
let mut fallback = None;
|
||||
if let Some((intact, bytes)) = editing {
|
||||
let native = if intact { bytes() } else { None };
|
||||
match native.and_then(|b| vectorcraft_format::load(&b).ok()) {
|
||||
Some(doc) => return Ok((doc, vec![], true)),
|
||||
match native.and_then(|b| native_file(&b).ok()) {
|
||||
Some((doc, _, warnings)) => return Ok((doc, warnings, true)),
|
||||
None => fallback = Some(if intact { EDITING_DAMAGED } else { EDITING_STALE }),
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,14 @@ fn restore_or_import(
|
||||
Ok((doc, warnings, false))
|
||||
}
|
||||
|
||||
/// A native file's document and details, the colour profiles it carries installed where missing
|
||||
/// (with a warning for each that can't be used).
|
||||
pub(crate) fn native_file(bytes: &[u8]) -> Result<(Document, vectorcraft_format::FileInfo, Vec<String>)> {
|
||||
let f = vectorcraft_format::load_file(bytes).map_err(err)?;
|
||||
let warnings = super::native::install_profiles(f.profiles);
|
||||
Ok((f.doc, f.info, warnings))
|
||||
}
|
||||
|
||||
/// Read a file of any readable format (`name`: its file name or path, for the extension and title;
|
||||
/// a path's folder is where an SVG's relative image links are found).
|
||||
pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
||||
@@ -123,9 +131,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let mut converted = false;
|
||||
let (mut doc, warnings, restored) = match format.id {
|
||||
"vectorcraft" | "template" => {
|
||||
let (d, info) = vectorcraft_format::load_info(bytes).map_err(err)?;
|
||||
let (d, info, warnings) = native_file(bytes)?;
|
||||
converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION;
|
||||
(d, vec![], false)
|
||||
(d, warnings, false)
|
||||
}
|
||||
"svg" | "svgz" => {
|
||||
let text = vectorcraft_svg::text_of(bytes).map_err(err)?;
|
||||
|
||||
@@ -43,6 +43,7 @@ pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub use export::export_source;
|
||||
use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
pub(crate) use load::native_file;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
|
||||
pub use native::with_compression_pref;
|
||||
@@ -360,7 +361,17 @@ pub const FORMATS: &[Format] = &[
|
||||
options: svg::OPTIONS,
|
||||
},
|
||||
Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS },
|
||||
reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false),
|
||||
Format {
|
||||
id: "ai",
|
||||
label: "PDF-compatible .ai",
|
||||
extensions: &["ai"],
|
||||
mime: "application/pdf",
|
||||
read: true,
|
||||
// Saved (Save As), not exported.
|
||||
write: false,
|
||||
raster: false,
|
||||
options: native::AI_OPTIONS,
|
||||
},
|
||||
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
|
||||
Format {
|
||||
id: "png",
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! The native format's options: compression, the version to write (older apps read older
|
||||
//! versions) and an embedded preview.
|
||||
//! versions), an embedded preview, and the save options native and `.ai` files share: each
|
||||
//! artboard to a separate file, Include Linked Files, Embed ICC Profiles and Create
|
||||
//! PDF-Compatible File.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms;
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_format::{PREVIEW_MAX, SaveOptions};
|
||||
|
||||
@@ -29,33 +34,146 @@ pub const OPTIONS: &[FormatOption] = &[
|
||||
default: "false",
|
||||
description: "embed a PNG preview of the first artboard (at most 256 pixels on its longer side) for file browsers",
|
||||
},
|
||||
SEPARATE_ARTBOARDS,
|
||||
SEPARATE_RANGE,
|
||||
INCLUDE_LINKED,
|
||||
EMBED_PROFILES,
|
||||
FormatOption {
|
||||
name: "pdfCompatible",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "also carry a PDF of every artboard, for apps that read PDF (a larger file)",
|
||||
},
|
||||
];
|
||||
|
||||
const SEPARATE_ARTBOARDS: FormatOption = FormatOption {
|
||||
name: "separateArtboards",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "also save each artboard (of range) to a file of its own beside this one, <name>-<artboard>.<ext>, holding that artboard and the art on it",
|
||||
};
|
||||
const SEPARATE_RANGE: FormatOption = FormatOption {
|
||||
name: "range",
|
||||
ty: "string",
|
||||
default: "null",
|
||||
description: "with separateArtboards: the artboards saved separately, 1-based such as \"1-3, 5\", or \"all\" (default)",
|
||||
};
|
||||
const INCLUDE_LINKED: FormatOption = FormatOption {
|
||||
name: "includeLinked",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "keep the linked files' own pixels in the file (they stay linked), not just a preview: the document shows them in full where the files can't be found",
|
||||
};
|
||||
const EMBED_PROFILES: FormatOption = FormatOption {
|
||||
name: "embedProfiles",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "carry the ICC profiles the document is tagged with that were loaded from files (Edit → Assign Profile), so the document keeps them where they aren't installed",
|
||||
};
|
||||
|
||||
/// The save options of a `.ai` file (a PDF carrying the native document; its PDF options come on
|
||||
/// top, see document.exportPdf).
|
||||
pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
SEPARATE_ARTBOARDS,
|
||||
SEPARATE_RANGE,
|
||||
INCLUDE_LINKED,
|
||||
EMBED_PROFILES,
|
||||
FormatOption {
|
||||
name: "pdfCompatible",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "write the artwork as PDF pages that other apps show; false keeps only the native document (smaller, faster; other apps show empty pages)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "compress",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct NativeOptions {
|
||||
compress: Option<bool>,
|
||||
version: Option<u32>,
|
||||
preview: bool,
|
||||
include_linked: bool,
|
||||
embed_profiles: Option<bool>,
|
||||
pdf_compatible: bool,
|
||||
}
|
||||
|
||||
/// The options in `p` for saving `doc`, checked (the preview is left to [`encode`]) → them and
|
||||
/// whether to embed a preview.
|
||||
pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, bool)> {
|
||||
/// What [`save_options`] leaves to [`encode`] (rendering and encoding take time).
|
||||
pub(super) struct Extras {
|
||||
preview: bool,
|
||||
pdf: bool,
|
||||
}
|
||||
|
||||
/// The options in `p` for saving `doc`, checked, with the profiles to embed (the preview and PDF
|
||||
/// are left to [`encode`]).
|
||||
pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, Extras)> {
|
||||
let o: NativeOptions = super::encode::options(f, p)?;
|
||||
let mut so = SaveOptions::for_doc(doc);
|
||||
so.compress = o.compress.unwrap_or(false);
|
||||
so.version = o.version.unwrap_or(vectorcraft_format::VERSION);
|
||||
so.include_linked = o.include_linked;
|
||||
if o.embed_profiles.unwrap_or(true) {
|
||||
so.profiles = embedded_profiles(doc);
|
||||
}
|
||||
so.check().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok((so, o.preview))
|
||||
Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible }))
|
||||
}
|
||||
|
||||
/// The `.vectorcraft` file of `doc` with the options in `p`.
|
||||
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
|
||||
let (mut so, preview) = save_options(cmd, f, doc, p)?;
|
||||
if preview {
|
||||
let (mut so, extras) = save_options(cmd, f, doc, p)?;
|
||||
if extras.preview {
|
||||
so.preview = preview_png(doc)?;
|
||||
}
|
||||
if extras.pdf {
|
||||
// Every artboard as PDF pages (no editing data: the file is the native document).
|
||||
so.pdf = Some(super::pdf::encode(cmd, doc, &json!({ "range": "all", "preserveEditing": false }))?.0);
|
||||
}
|
||||
vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in
|
||||
/// ones are there wherever VectorCraft runs).
|
||||
fn embedded_profiles(doc: &Document) -> BTreeMap<String, Vec<u8>> {
|
||||
[&doc.color_profiles.rgb, &doc.color_profiles.cmyk]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|name| cms::profile(name).filter(|p| !p.builtin))
|
||||
.filter_map(|p| Some((p.name.clone(), cms::icc_bytes(&p.name).ok()?.to_vec())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Most profiles read from one file.
|
||||
const MAX_PROFILES: usize = 8;
|
||||
|
||||
/// Make the profiles a file carries available (those not installed here), named as in the file →
|
||||
/// a warning per profile that can't be used.
|
||||
pub(super) fn install_profiles(profiles: BTreeMap<String, Vec<u8>>) -> Vec<String> {
|
||||
// A document is tagged with an RGB and a CMYK profile at most: more is junk.
|
||||
profiles
|
||||
.into_iter()
|
||||
.take(MAX_PROFILES)
|
||||
.filter(|(name, _)| cms::profile(name).is_none())
|
||||
.filter_map(|(name, icc)| {
|
||||
cms::register_icc(&icc, Some(name.clone())).err().map(|e| format!("the colour profile “{name}” the file carries can't be used: {e}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The native document a `.ai` file carries, with the options in `p` that apply to it
|
||||
/// (`includeLinked`, `embedProfiles`; compact and uncompressed: the PDF compresses it).
|
||||
pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.retain(|k, _| !matches!(k.as_str(), "compress" | "version" | "preview" | "pdfCompatible"));
|
||||
}
|
||||
let (mut so, _) = save_options(cmd, f, doc, &q)?;
|
||||
so.pretty = false;
|
||||
vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
|
||||
@@ -197,11 +197,17 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str =
|
||||
/// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself
|
||||
/// when the PDF has every artboard (a PDF of some reopens as just those).
|
||||
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
encode_carrying(cmd, doc, p, || Ok(vectorcraft_format::save(doc, false)))
|
||||
}
|
||||
|
||||
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
|
||||
/// native document with its save options, whose pages may be left blank).
|
||||
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
let mut opts = options(cmd, doc, p)?;
|
||||
let mut warnings = vec![];
|
||||
if opts.settings.preserve_editing {
|
||||
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
|
||||
opts.native = Some(vectorcraft_format::save(doc, false));
|
||||
opts.native = Some(native()?);
|
||||
} else {
|
||||
opts.settings.preserve_editing = false;
|
||||
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
|
||||
|
||||
@@ -13,7 +13,8 @@ use vectorcraft_doc::Document;
|
||||
|
||||
use super::super::*;
|
||||
use super::{
|
||||
Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, write_file,
|
||||
ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded,
|
||||
write_file,
|
||||
};
|
||||
use crate::{DocState, Prefs};
|
||||
|
||||
@@ -198,10 +199,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
/// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General
|
||||
/// settings (`createLayers`…) and presets.
|
||||
fn reads_option(f: &Format, key: &str) -> bool {
|
||||
match is_pdf(f) {
|
||||
true => super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key),
|
||||
false => f.options.iter().any(|o| o.name == key),
|
||||
}
|
||||
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
|
||||
}
|
||||
|
||||
/// Formats that carry the whole native document: they lose nothing, and record links relative to
|
||||
@@ -251,8 +249,62 @@ pub struct SaveJob {
|
||||
params: Value,
|
||||
/// The document when the job was made: what counts as saved once the file is written.
|
||||
snapshot: Arc<Document>,
|
||||
/// The artboards also saved to files of their own (`separateArtboards`).
|
||||
boards: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The artboards a native or `.ai` save also writes to files of their own: with
|
||||
/// `separateArtboards`, those `range` names (default all).
|
||||
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string);
|
||||
let n = doc.artboards.len();
|
||||
let picked = ArtboardPick { range, ..Default::default() }.resolve(n).map_err(|e| bad(cmd, e))?;
|
||||
Ok(picked.unwrap_or_else(|| (0..n).collect()))
|
||||
}
|
||||
|
||||
/// `doc` reduced to artboard `i` and the art on it (Save each artboard to a separate file):
|
||||
/// objects whose bounds touch the artboard, in their layers; resources are kept.
|
||||
fn artboard_doc(doc: &Document, i: usize) -> Document {
|
||||
let mut d = doc.clone();
|
||||
let Some(a) = doc.artboards.get(i) else { return d };
|
||||
let r = a.rect;
|
||||
d.artboards = vec![a.clone()];
|
||||
// It opens fitted to its artboard, not at the master file's view.
|
||||
d.last_view = None;
|
||||
for layer in &mut d.layers {
|
||||
keep_on(layer, r);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Keep in layer `n` (and its sublayers) only the objects touching `r`.
|
||||
fn keep_on(n: &mut Arc<vectorcraft_doc::Node>, r: vectorcraft_geom::Rect) {
|
||||
let touches = |b: vectorcraft_geom::Rect| b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0;
|
||||
if let Some(children) = Arc::make_mut(n).children_mut() {
|
||||
children.retain(|c| c.is_layer() || c.visual_bounds().is_some_and(touches));
|
||||
for c in children.iter_mut().filter(|c| c.is_layer()) {
|
||||
keep_on(c, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` with empty layers: the pages of a `.ai` file saved without PDF content.
|
||||
fn blank_pages(doc: &Document) -> Document {
|
||||
let mut d = doc.clone();
|
||||
for layer in &mut d.layers {
|
||||
if let Some(children) = Arc::make_mut(layer).children_mut() {
|
||||
children.clear();
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
|
||||
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
|
||||
|
||||
/// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the
|
||||
/// document's own). Bad options fail here, before anything is encoded.
|
||||
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
|
||||
@@ -285,12 +337,20 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<Save
|
||||
params = with_compression_pref(&s.prefs, ¶ms);
|
||||
super::native::save_options(cmd, plan.format, &doc, ¶ms)?;
|
||||
}
|
||||
let boards = separate_boards(cmd, plan.format, &doc, ¶ms)?;
|
||||
let mut params = super::pdf::expand_preset(s, cmd, ¶ms)?.into_owned();
|
||||
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
|
||||
// A PDF of every artboard with the PDF options, always carrying the native document.
|
||||
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
|
||||
// Use Compression compresses the PDF's content.
|
||||
if let Some(c) = o.get("compress").and_then(Value::as_bool) {
|
||||
let compression = o.entry("compression").or_insert_with(|| json!({}));
|
||||
if let Some(m) = compression.as_object_mut() {
|
||||
m.insert("compressText".into(), json!(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone() })
|
||||
Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone(), boards })
|
||||
}
|
||||
|
||||
impl SaveJob {
|
||||
@@ -303,26 +363,45 @@ impl SaveJob {
|
||||
&self.snapshot
|
||||
}
|
||||
|
||||
/// Encode the file → it, with the warnings (what the format loses first, then the encoder's
|
||||
/// own notes: PDF options not applied yet…).
|
||||
/// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with
|
||||
/// the warnings (what the format loses first, then the encoder's own notes: PDF options not
|
||||
/// applied yet…).
|
||||
pub fn encode(&self) -> Result<Encoded> {
|
||||
let (cmd, plan, doc) = (self.plan.mode.command(), &self.plan, &*self.doc);
|
||||
let mut enc = if plan.format.id == "ai" {
|
||||
let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &self.params)?;
|
||||
let mut enc = self.encode_one(&self.doc)?;
|
||||
for &b in &self.boards {
|
||||
let one = self.encode_one(&artboard_doc(&self.doc, b))?;
|
||||
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
|
||||
}
|
||||
let pdf_less = self.plan.format.id == "ai" && !bool_or(&self.params, "pdfCompatible", true);
|
||||
let notes = fidelity_warning(self.plan.format).into_iter().chain(pdf_less.then(|| NOT_PDF_COMPATIBLE.to_string()));
|
||||
enc.warnings.splice(0..0, notes);
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// Encode one file of `doc` in the job's format.
|
||||
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let enc = if f.id == "ai" {
|
||||
// The pages (blank without PDF content), carrying the native document.
|
||||
let pages = doc.without_edit_modes();
|
||||
let pages = if bool_or(&self.params, "pdfCompatible", true) { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) };
|
||||
let native = || super::native::ai_native(cmd, f, doc, &self.params);
|
||||
let (bytes, warnings) = super::pdf::encode_carrying(cmd, &pages, &self.params, native)?;
|
||||
Encoded { warnings, ..Encoded::one(bytes) }
|
||||
} else {
|
||||
encode_all(doc, plan.format.id, &self.params)?
|
||||
encode_all(doc, f.id, &self.params)?
|
||||
};
|
||||
if enc.files.len() != 1 {
|
||||
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
enc.warnings.splice(0..0, fidelity_warning(plan.format));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// Encode the file and write it with `write` (the file, then the images an SVG links to,
|
||||
/// beside it). Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?:
|
||||
/// [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, linked?: [path…]}`.
|
||||
/// Encode the file and write it with `write` (the file, then its artboards' files and the
|
||||
/// images an SVG links to, beside it). Without a path → `{dataBase64, bytes, format, name,
|
||||
/// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several),
|
||||
/// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?:
|
||||
/// [path…], linked?: [path…]}`.
|
||||
pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let (plan, doc) = (&self.plan, &*self.doc);
|
||||
let mut enc = self.encode()?;
|
||||
@@ -342,7 +421,11 @@ impl SaveJob {
|
||||
}
|
||||
let bytes = files.first().map_or(0, |f| f.1.len());
|
||||
let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra);
|
||||
if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) {
|
||||
let (main, linked) = files.split_at(enc.files.len().min(files.len()));
|
||||
if main.len() > 1 {
|
||||
out["files"] = main.iter().map(|(p, _)| json!(p)).collect();
|
||||
}
|
||||
if !linked.is_empty() {
|
||||
out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect();
|
||||
}
|
||||
Ok(out)
|
||||
@@ -420,7 +503,9 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.formatOptions";
|
||||
let st = s.active();
|
||||
let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft");
|
||||
let f = format(id).filter(|f| f.write).ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
|
||||
let f = format(id)
|
||||
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id))
|
||||
.ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
|
||||
let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options);
|
||||
// A native save compresses as Use Compression says, unless told otherwise.
|
||||
let prefs = with_compression_pref(&s.prefs, &json!({}));
|
||||
@@ -443,7 +528,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), native and .ai: separateArtboards?: false (also save each artboard of range?: \"1-3, 5\"|\"all\" (default) to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document), .ai: compress?: true (compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -735,7 +735,7 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
|
||||
let bytes = store.read(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?;
|
||||
let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default();
|
||||
let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default();
|
||||
let mut doc = vectorcraft_format::load(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?;
|
||||
let (mut doc, _, warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?;
|
||||
let area = claim(s, store)?;
|
||||
let mut taken: Vec<String> = copies_in(&store.list().map_err(EngineError::Other)?).into_iter().map(|(_, f)| f).collect();
|
||||
taken.extend(open_copies(s));
|
||||
@@ -761,7 +761,7 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
|
||||
// The copy stays until the document is saved or closed.
|
||||
st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() });
|
||||
let title = st.title();
|
||||
Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path }), links.to_json()))
|
||||
Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json()))
|
||||
}
|
||||
|
||||
fn restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -823,7 +823,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Restore Recovered Documents",
|
||||
[],
|
||||
None,
|
||||
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed",
|
||||
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed",
|
||||
has_store,
|
||||
restore
|
||||
),
|
||||
|
||||
@@ -1205,6 +1205,8 @@ mod tests_registration;
|
||||
#[cfg(test)]
|
||||
mod tests_save;
|
||||
#[cfg(test)]
|
||||
mod tests_saveoptions;
|
||||
#[cfg(test)]
|
||||
mod tests_scalestrokes;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Native and `.ai` save options: each artboard to a separate file (a master file too), Include
|
||||
//! Linked Files, Embed ICC Profiles, Create PDF-Compatible File and the `.ai` Use Compression.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms;
|
||||
|
||||
use super::*;
|
||||
use crate::tests_links::{Folder, png};
|
||||
|
||||
/// Three artboards (named A, B and C) with a rectangle on each.
|
||||
fn three_boards() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "A"})).unwrap();
|
||||
for (i, name) in ["B", "C"].iter().enumerate() {
|
||||
let x = 200.0 * (i + 1) as f64;
|
||||
s.execute("artboard.new", &json!({"x": x, "y": 0, "width": 100, "height": 100, "name": name})).unwrap();
|
||||
}
|
||||
for x in [10, 210, 410] {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn objects(d: &vectorcraft_doc::Document) -> usize {
|
||||
d.layers.iter().map(|l| l.count() - 1).sum()
|
||||
}
|
||||
|
||||
fn bytes_of(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v.as_str().expect("base64")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_artboards_give_four_files() {
|
||||
let dir = Folder::new("separate");
|
||||
let mut s = three_boards();
|
||||
let path = dir.file("Poster.vectorcraft");
|
||||
let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap();
|
||||
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
|
||||
assert_eq!(files, [path.clone(), dir.file("Poster-A.vectorcraft"), dir.file("Poster-B.vectorcraft"), dir.file("Poster-C.vectorcraft")]);
|
||||
// The master file has everything; each artboard's file that artboard and its art.
|
||||
let master = vectorcraft_format::load(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!((master.artboards.len(), objects(&master)), (3, 3));
|
||||
for (f, name, x) in [(files[1], "A", 0.0), (files[2], "B", 200.0), (files[3], "C", 400.0)] {
|
||||
let d = vectorcraft_format::load(&std::fs::read(f).unwrap()).unwrap();
|
||||
assert_eq!((d.artboards.len(), d.artboards[0].name.as_str(), d.artboards[0].rect.x0), (1, name, x));
|
||||
assert_eq!(objects(&d), 1, "{name}");
|
||||
}
|
||||
// The document takes the master file; Save remembers the choice.
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.path.as_deref(), st.is_dirty()), (Some(path.as_str()), false));
|
||||
// A range: the master and two artboards.
|
||||
let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true, "range": "2-3"})).unwrap();
|
||||
assert_eq!(r["files"].as_array().unwrap().len(), 3);
|
||||
assert!(s.execute("document.save", &json!({"path": path, "separateArtboards": true, "range": "4"})).is_err(), "no fourth artboard");
|
||||
// No path: the bytes of every file.
|
||||
let r = s.execute("file.saveCopy", &json!({"separateArtboards": true})).unwrap();
|
||||
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Poster copy.vectorcraft", "Poster copy-A.vectorcraft", "Poster copy-B.vectorcraft", "Poster copy-C.vectorcraft"]);
|
||||
assert!(vectorcraft_format::sniff(&bytes_of(&r["files"][3]["dataBase64"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_files_save_each_artboard_too_and_reopen_editable() {
|
||||
let dir = Folder::new("separate-ai");
|
||||
let mut s = three_boards();
|
||||
let path = dir.file("Set.ai");
|
||||
let r = s.execute("file.saveAs", &json!({"path": path, "separateArtboards": true, "range": "1, 3"})).unwrap();
|
||||
assert_eq!(r["files"], json!([path, dir.file("Set-A.ai"), dir.file("Set-C.ai")]));
|
||||
let r = s.execute("document.open", &json!({"path": dir.file("Set-C.ai")})).unwrap();
|
||||
assert_eq!(r["restored"], true);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!((d.artboards.len(), d.artboards[0].name.as_str(), objects(d)), (1, "C", 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_pngs_bytes_are_embedded() {
|
||||
let dir = Folder::new("include-linked");
|
||||
let pic = dir.file("photo.png");
|
||||
let bytes = png(400, 300, [200, 30, 30]);
|
||||
std::fs::write(&pic, &bytes).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
|
||||
s.execute("file.place", &json!({"path": pic, "link": true})).unwrap();
|
||||
let images = |v: &Value| -> Value {
|
||||
let text = bytes_of(&v["dataBase64"]);
|
||||
serde_json::from_slice::<Value>(&text).unwrap()["images"].clone()
|
||||
};
|
||||
// By default only the preview.
|
||||
let plain = images(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap());
|
||||
let (_, image) = plain.as_object().unwrap().iter().next().unwrap();
|
||||
assert_eq!(image["proxy"], true);
|
||||
// Include Linked Files: the file's own bytes.
|
||||
let r = s.execute("file.saveCopy", &json!({"compress": false, "includeLinked": true})).unwrap();
|
||||
let full = images(&r);
|
||||
let (_, image) = full.as_object().unwrap().iter().next().unwrap();
|
||||
assert!(image.get("proxy").is_none());
|
||||
assert_eq!(vectorcraft_format::base64_decode(image["data"].as_str().unwrap()).unwrap(), bytes);
|
||||
// With the file gone the document still shows the full pixels.
|
||||
std::fs::remove_file(&pic).unwrap();
|
||||
let r = s.execute("document.open", &json!({"name": "copy.vectorcraft", "dataBase64": r["dataBase64"]})).unwrap();
|
||||
assert_eq!(r["missingLinks"].as_array().unwrap().len(), 1);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let blob = d.images.values().next().unwrap();
|
||||
assert_eq!(blob.bytes.as_slice(), bytes.as_slice());
|
||||
let mut linked = 0;
|
||||
d.visit_images(|_, im| linked += usize::from(im.link.is_some()));
|
||||
assert_eq!(linked, 1, "still linked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_profile_is_restored() {
|
||||
let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap();
|
||||
let icc = cms::icc_bytes(&builtin.name).unwrap();
|
||||
let name = format!("Studio RGB {}", std::process::id());
|
||||
cms::register_icc(&icc, Some(name.clone())).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
Arc::make_mut(&mut s.doc_mut().unwrap().doc).color_profiles.rgb = Some(name.clone());
|
||||
let file = bytes_of(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()["dataBase64"]);
|
||||
let v: Value = serde_json::from_slice(&file).unwrap();
|
||||
assert_eq!(vectorcraft_format::base64_decode(v["profiles"][&name]["data"].as_str().unwrap()).unwrap(), icc.to_vec());
|
||||
// Not embedded when asked not to; built-in profiles never are (they are everywhere).
|
||||
let none: Value =
|
||||
serde_json::from_slice(&bytes_of(&s.execute("file.saveCopy", &json!({"compress": false, "embedProfiles": false})).unwrap()["dataBase64"]))
|
||||
.unwrap();
|
||||
assert!(none.get("profiles").is_none());
|
||||
// Elsewhere (a profile name this machine lacks): opening installs it and the document keeps it.
|
||||
let elsewhere = format!("Studio RGB elsewhere {}", std::process::id());
|
||||
let moved = String::from_utf8(file).unwrap().replace(&name, &elsewhere);
|
||||
assert!(cms::profile(&elsewhere).is_none());
|
||||
let r =
|
||||
s.execute("document.open", &json!({"name": "moved.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(moved.as_bytes())})).unwrap();
|
||||
assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert_eq!(cms::profile(&elsewhere).map(|p| p.kind), Some(cms::ProfileKind::Rgb));
|
||||
assert_eq!(s.doc().unwrap().doc.color_profiles.rgb.as_deref(), Some(elsewhere.as_str()));
|
||||
// Damaged profile data opens the document with a warning.
|
||||
let broken = format!("Studio RGB broken {}", std::process::id());
|
||||
let mut v: Value = serde_json::from_str(&moved.replace(&elsewhere, &broken)).unwrap();
|
||||
v["profiles"][&broken]["data"] = json!("AAAA");
|
||||
let r = s
|
||||
.execute(
|
||||
"document.open",
|
||||
&json!({"name": "b.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&serde_json::to_vec(&v).unwrap())}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r["warnings"][0].as_str().unwrap().contains(&broken), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_compatible_files_carry_a_pdf() {
|
||||
let mut s = three_boards();
|
||||
let file = bytes_of(&s.execute("file.saveCopy", &json!({"pdfCompatible": true})).unwrap()["dataBase64"]);
|
||||
let pdf = vectorcraft_format::pdf_content(&file).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap();
|
||||
assert_eq!(info["pages"], 3);
|
||||
assert!(vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({})).unwrap()["dataBase64"])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_without_pdf_content_and_compression() {
|
||||
let mut s = three_boards();
|
||||
let ai = |s: &mut Session, p: Value| -> (Vec<u8>, Value) {
|
||||
let mut p = p;
|
||||
p["format"] = json!("ai");
|
||||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||||
(bytes_of(&r["dataBase64"]), r["warnings"].clone())
|
||||
};
|
||||
let (full, _) = ai(&mut s, json!({}));
|
||||
let (blank, warnings) = ai(&mut s, json!({"pdfCompatible": false}));
|
||||
assert!(warnings.as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("without PDF content")), "{warnings}");
|
||||
// The pages are empty (an import of a page finds no art), the document comes back whole.
|
||||
let page = |s: &mut Session, b: &[u8]| {
|
||||
s.execute("document.open", &json!({"name": "x.ai", "dataBase64": vectorcraft_format::base64_encode(b), "pages": "1"})).unwrap();
|
||||
objects(&s.doc().unwrap().doc)
|
||||
};
|
||||
assert!(page(&mut s, &full) > 0);
|
||||
assert_eq!(page(&mut s, &blank), 0);
|
||||
s.execute("document.open", &json!({"name": "x.ai", "dataBase64": vectorcraft_format::base64_encode(&blank)})).unwrap();
|
||||
assert_eq!(objects(&s.doc().unwrap().doc), 3);
|
||||
// Use Compression off writes the content uncompressed: a larger file.
|
||||
let mut s = three_boards();
|
||||
let (packed, _) = ai(&mut s, json!({"compress": true}));
|
||||
let (loose, _) = ai(&mut s, json!({"compress": false}));
|
||||
assert!(loose.len() > packed.len(), "{} vs {}", loose.len(), packed.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_options_list_the_save_options() {
|
||||
let mut s = three_boards();
|
||||
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
|
||||
for (k, value) in [("separateArtboards", false), ("includeLinked", false), ("embedProfiles", true), ("pdfCompatible", true), ("compress", true)] {
|
||||
assert_eq!(v["options"][k]["value"], value, "{k}");
|
||||
}
|
||||
let v = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap();
|
||||
assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(false), true));
|
||||
// Saved with them, a document remembers them for Save.
|
||||
let dir = Folder::new("remember");
|
||||
let path = dir.file("r.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path, "includeLinked": true})).unwrap();
|
||||
let v = s.execute("file.formatOptions", &json!({})).unwrap();
|
||||
assert_eq!(v["options"]["includeLinked"]["value"], true);
|
||||
}
|
||||
+69
-15
@@ -5,15 +5,20 @@
|
||||
//! ```json
|
||||
//! { "format": "vectorcraft", "version": 3, "generator": "VectorCraft 0.1.0",
|
||||
//! "preview": { "mime": "image/png", "data": "<base64>" },
|
||||
//! "profiles": { "<profile name>": { "mime": "application/vnd.iccprofile", "data": "<base64>" } },
|
||||
//! "pdf": { "mime": "application/pdf", "data": "<base64>" },
|
||||
//! "document": { …vectorcraft_doc::Document… },
|
||||
//! "images": { "<key>": { "mime": "image/png", "data": "<base64>" } } }
|
||||
//! ```
|
||||
//! It is lossless for everything in the document model; foreign data lives in `document.unknown`
|
||||
//! and document keys a newer version added are kept (`Document::extra`). Only the images the
|
||||
//! document uses are written. `preview` (optional) is a PNG of the first artboard, at most
|
||||
//! [`PREVIEW_MAX`] pixels on its longer side, for file browsers. An image only linked images show
|
||||
//! is saved as its low-resolution preview (`"proxy": true`); the engine reads the linked file again
|
||||
//! when the document opens.
|
||||
//! [`PREVIEW_MAX`] pixels on its longer side, for file browsers. `profiles` (optional, Embed ICC
|
||||
//! Profiles) holds the ICC files of colour profiles the document is tagged with, for machines that
|
||||
//! don't have them ([`load_file`] returns them). `pdf` (optional, Create PDF-Compatible File) is a
|
||||
//! PDF of every artboard for apps that read PDF ([`pdf_content`]). An image only linked images
|
||||
//! show is saved as its low-resolution preview (`"proxy": true`), unless saved with Include Linked
|
||||
//! Files; the engine reads the linked file again when the document opens.
|
||||
//!
|
||||
//! Versions: v1 wrote anchors as `{p: {x, y}, in: {x, y}, out: {x, y}, kind}`; v2 as
|
||||
//! `{p: [x, y], in?, out?, kind?}` with default-valued fields left out; v3 keeps the assigned colour
|
||||
@@ -95,6 +100,8 @@ struct File {
|
||||
document: Document,
|
||||
#[serde(default)]
|
||||
images: BTreeMap<String, Image>,
|
||||
#[serde(default)]
|
||||
profiles: BTreeMap<String, Image>,
|
||||
}
|
||||
|
||||
/// How [`save_with`] writes a document.
|
||||
@@ -108,14 +115,23 @@ pub struct SaveOptions {
|
||||
pub version: u32,
|
||||
/// A PNG preview to embed (at most [`PREVIEW_MAX`] pixels on its longer side).
|
||||
pub preview: Option<Vec<u8>>,
|
||||
/// Include Linked Files: linked images keep the file's own bytes, not just their preview.
|
||||
pub include_linked: bool,
|
||||
/// Embed ICC Profiles: ICC files by profile name.
|
||||
pub profiles: BTreeMap<String, Vec<u8>>,
|
||||
/// Create PDF-Compatible File: a PDF of the document to carry.
|
||||
pub pdf: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Default for SaveOptions {
|
||||
fn default() -> Self {
|
||||
Self { pretty: false, compress: false, version: VERSION, preview: None }
|
||||
Self { pretty: false, compress: false, version: VERSION, preview: None, include_linked: false, profiles: BTreeMap::new(), pdf: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The MIME type of embedded ICC profiles.
|
||||
pub const ICC_MIME: &str = "application/vnd.iccprofile";
|
||||
|
||||
impl SaveOptions {
|
||||
/// Can a file be written this way (a version [`save_with`] writes, compressed only for readers
|
||||
/// that open compressed files)?
|
||||
@@ -177,21 +193,33 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result<Vec<u8>, FormatError
|
||||
.iter()
|
||||
.filter(|(k, _)| used.contains(k.as_str()))
|
||||
.map(|(k, b)| {
|
||||
let image = match b.proxy.as_ref().filter(|_| linked.contains(k)) {
|
||||
// Include Linked Files keeps the file's bytes whenever they are loaded.
|
||||
let preview_only = linked.contains(k) && (!o.include_linked || b.is_proxy());
|
||||
let image = match b.proxy.as_ref().filter(|_| preview_only) {
|
||||
Some(p) => Image { mime: vectorcraft_doc::links::PROXY_MIME.into(), data: base64_encode(p), proxy: true },
|
||||
None => Image { mime: b.mime.clone(), data: base64_encode(&b.bytes), proxy: false },
|
||||
};
|
||||
(k.as_str(), image)
|
||||
})
|
||||
.collect();
|
||||
let size = body.len() + images.values().map(|i| i.data.len()).sum::<usize>() + 256;
|
||||
let blob = |mime: &str, bytes: &[u8]| Image { mime: mime.into(), data: base64_encode(bytes), proxy: false };
|
||||
let profiles: BTreeMap<&str, Image> = o.profiles.iter().map(|(name, icc)| (name.as_str(), blob(ICC_MIME, icc))).collect();
|
||||
let pdf = o.pdf.as_deref().map(|p| blob("application/pdf", p));
|
||||
let carried = profiles.values().chain(&pdf).map(|i| i.data.len()).sum::<usize>();
|
||||
let size = body.len() + images.values().map(|i| i.data.len()).sum::<usize>() + carried + 256;
|
||||
let mut w = Envelope { out: Vec::with_capacity(size), pretty };
|
||||
// Older apps only know the name from before the rename.
|
||||
w.field("format", &if o.version < VERSION { LEGACY_EXTENSION } else { EXTENSION })?;
|
||||
w.field("version", &o.version)?;
|
||||
w.field("generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?;
|
||||
if let Some(png) = &o.preview {
|
||||
w.field("preview", &Image { mime: "image/png".into(), data: base64_encode(png), proxy: false })?;
|
||||
w.field("preview", &blob("image/png", png))?;
|
||||
}
|
||||
if !profiles.is_empty() {
|
||||
w.field("profiles", &profiles)?;
|
||||
}
|
||||
if let Some(pdf) = &pdf {
|
||||
w.field("pdf", pdf)?;
|
||||
}
|
||||
w.raw("document", &body);
|
||||
w.field("images", &images)?;
|
||||
@@ -295,6 +323,15 @@ pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
load_info(bytes).map(|(doc, _)| doc)
|
||||
}
|
||||
|
||||
/// A native file read: its document, what it says about itself and the colour profiles it carries.
|
||||
#[derive(Debug)]
|
||||
pub struct NativeFile {
|
||||
pub doc: Document,
|
||||
pub info: FileInfo,
|
||||
/// ICC files by profile name (saved with Embed ICC Profiles; undecodable ones are left out).
|
||||
pub profiles: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
/// What a native file says about itself besides its document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct FileInfo {
|
||||
@@ -313,6 +350,11 @@ impl FileInfo {
|
||||
|
||||
/// [`load`], also telling the file's version and name.
|
||||
pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> {
|
||||
load_file(bytes).map(|f| (f.doc, f.info))
|
||||
}
|
||||
|
||||
/// [`load`], with everything else the file carries.
|
||||
pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
|
||||
let text = unpack(bytes)?;
|
||||
let f: File = serde_json::from_slice(&text).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
|
||||
if f.format != EXTENSION && f.format != LEGACY_EXTENSION {
|
||||
@@ -338,18 +380,30 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> {
|
||||
// Saved before v3: the assigned profiles move out of `unknown`.
|
||||
doc.migrate_color_profiles();
|
||||
doc.fix_next_id();
|
||||
Ok((doc, FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }))
|
||||
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, base64_decode(&icc.data)?))).collect();
|
||||
Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
|
||||
}
|
||||
|
||||
/// The members of a native file besides its document (which is skipped, not decoded).
|
||||
#[derive(Deserialize)]
|
||||
struct Head {
|
||||
preview: Option<Image>,
|
||||
pdf: Option<Image>,
|
||||
}
|
||||
|
||||
fn head(bytes: &[u8]) -> Option<Head> {
|
||||
serde_json::from_slice(&unpack(bytes).ok()?).ok()
|
||||
}
|
||||
|
||||
/// The preview PNG embedded in a native file (`None`: it has none, or isn't a native file).
|
||||
pub fn preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
#[derive(Deserialize)]
|
||||
struct Head {
|
||||
preview: Option<Image>,
|
||||
}
|
||||
let text = unpack(bytes).ok()?;
|
||||
let head: Head = serde_json::from_slice(&text).ok()?;
|
||||
base64_decode(&head.preview?.data)
|
||||
base64_decode(&head(bytes)?.preview?.data)
|
||||
}
|
||||
|
||||
/// The PDF a native file saved with Create PDF-Compatible File carries (`None`: it has none, or
|
||||
/// isn't a native file).
|
||||
pub fn pdf_content(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
base64_decode(&head(bytes)?.pdf?.data)
|
||||
}
|
||||
|
||||
/// Does this look like a `.vectorcraft` file (compressed or not)?
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeId, NodeKind};
|
||||
use vectorcraft_format::{SaveOptions, base64_decode, base64_encode, load, preview, save, save_with, sniff};
|
||||
use vectorcraft_format::{SaveOptions, base64_decode, base64_encode, load, load_file, pdf_content, preview, save, save_with, sniff};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_testkit::fixtures;
|
||||
use vectorcraft_testkit::invariants::{check_document, check_native_roundtrip, check_native_roundtrip_exact, doc_json, json_approx_eq};
|
||||
@@ -113,6 +113,26 @@ proptest! {
|
||||
check_document(&d).map_err(TestCaseError::fail)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutating a file carrying ICC profiles and a PDF (save options) never panics the readers.
|
||||
#[test]
|
||||
fn load_mutated_file_with_profiles_never_panics(cut in 0usize..3000, flips in prop::collection::vec((0usize..3000, any::<u8>()), 0..8)) {
|
||||
let o = SaveOptions {
|
||||
profiles: [("Studio RGB".to_string(), vec![1, 2, 3]), ("Press".to_string(), vec![])].into(),
|
||||
pdf: Some(b"%PDF-1.7".to_vec()),
|
||||
..SaveOptions::default()
|
||||
};
|
||||
let mut bytes = save_with(&Document::new(100.0, 100.0), &o).unwrap();
|
||||
for (i, b) in flips {
|
||||
let n = bytes.len();
|
||||
bytes[i % n] = b;
|
||||
}
|
||||
bytes.truncate(cut.min(bytes.len()));
|
||||
let _ = pdf_content(&bytes);
|
||||
if let Ok(f) = load_file(&bytes) {
|
||||
check_document(&f.doc).map_err(TestCaseError::fail)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! The native save options of the file itself: Include Linked Files, embedded ICC profiles and the
|
||||
//! PDF a PDF-compatible file carries, each round-tripping (and ignored by readers that don't
|
||||
//! need them).
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::links::LinkInfo;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_format::{ICC_MIME, SaveOptions, load, load_file, pdf_content, save_with};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
fn json_of(bytes: &[u8]) -> Value {
|
||||
serde_json::from_slice(bytes).unwrap()
|
||||
}
|
||||
|
||||
/// A document showing image `k` linked to a file; the blob holds the file's bytes and a preview.
|
||||
fn linked_doc() -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject {
|
||||
key: "k".into(),
|
||||
width: 2,
|
||||
height: 2,
|
||||
xf: Affine::IDENTITY,
|
||||
link: Some(LinkInfo::new("/art/photo.png")),
|
||||
placement: Default::default(),
|
||||
};
|
||||
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
|
||||
let mut blob = ImageBlob::new("image/png", vec![1, 2, 3, 4, 5, 6]);
|
||||
blob.proxy = Some(Arc::new(vec![9, 9]));
|
||||
d.images.insert("k".into(), blob);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_linked_files_keeps_the_files_bytes() {
|
||||
let d = linked_doc();
|
||||
let plain = save_with(&d, &SaveOptions::default()).unwrap();
|
||||
let image = &json_of(&plain)["images"]["k"];
|
||||
assert_eq!(image["proxy"], true, "only the preview by default");
|
||||
let full = save_with(&d, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap();
|
||||
let image = &json_of(&full)["images"]["k"];
|
||||
assert!(image.get("proxy").is_none());
|
||||
let back = load(&full).unwrap();
|
||||
assert_eq!(back.images["k"].bytes.as_slice(), &[1, 2, 3, 4, 5, 6]);
|
||||
assert!(!back.images["k"].is_proxy());
|
||||
// Only the preview loaded (the file was never found): the preview is all there is to write.
|
||||
let mut missing = linked_doc();
|
||||
let preview = Arc::new(vec![9, 9]);
|
||||
missing.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) });
|
||||
let out = save_with(&missing, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap();
|
||||
assert_eq!(json_of(&out)["images"]["k"]["proxy"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_profiles_and_pdf_round_trip() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.color_profiles.rgb = Some("Studio RGB".into());
|
||||
let o = SaveOptions {
|
||||
profiles: [("Studio RGB".to_string(), vec![0, 1, 2, 3, 250])].into(),
|
||||
pdf: Some(b"%PDF-1.7 fake".to_vec()),
|
||||
compress: true,
|
||||
..SaveOptions::default()
|
||||
};
|
||||
let bytes = save_with(&d, &o).unwrap();
|
||||
let f = load_file(&bytes).unwrap();
|
||||
assert_eq!(f.profiles["Studio RGB"], vec![0, 1, 2, 3, 250]);
|
||||
assert_eq!(f.doc.color_profiles.rgb.as_deref(), Some("Studio RGB"));
|
||||
assert_eq!(pdf_content(&bytes).unwrap(), b"%PDF-1.7 fake");
|
||||
// The plain file says what they are; a file without them has neither member.
|
||||
let plain = save_with(&d, &SaveOptions { compress: false, ..o }).unwrap();
|
||||
let v = json_of(&plain);
|
||||
assert_eq!((v["profiles"]["Studio RGB"]["mime"].as_str(), v["pdf"]["mime"].as_str()), (Some(ICC_MIME), Some("application/pdf")));
|
||||
let none = save_with(&d, &SaveOptions::default()).unwrap();
|
||||
assert!(json_of(&none).get("profiles").is_none() && pdf_content(&none).is_none());
|
||||
assert!(load_file(&none).unwrap().profiles.is_empty());
|
||||
// Damaged profile data is left out rather than failing the open.
|
||||
let damaged = String::from_utf8(plain).unwrap().replacen("AAECA/o=", "not base64!", 1);
|
||||
assert!(load_file(damaged.as_bytes()).unwrap().profiles.is_empty());
|
||||
}
|
||||
@@ -5,11 +5,14 @@
|
||||
//!
|
||||
//! Fields: `__action` (the save command it finishes), `__pick` (file name and format
|
||||
//! editable), `path`, `format`, and one field per option (agents set them with `ui.dialog.set`).
|
||||
//! Native and `.ai` files show their save options (VectorCraft Options after Save As): each
|
||||
//! artboard to a separate file, with `all` (true: every artboard) or `range`, Include Linked Files,
|
||||
//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{self, Format, FormatOption, SAVE_FORMATS, SaveMode};
|
||||
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format, FormatOption, SAVE_FORMATS, SaveMode};
|
||||
|
||||
use super::{DialogResult, DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
@@ -52,12 +55,58 @@ fn own_dialog(f: &Format) -> bool {
|
||||
matches!(f.id, "svg" | "svgz" | "pdf")
|
||||
}
|
||||
|
||||
/// Does `f` save each artboard to a separate file on request (native and `.ai` files)? Its
|
||||
/// `range` then shows under that checkbox.
|
||||
fn separates(f: &Format) -> bool {
|
||||
f.options.iter().any(|o| o.name == "separateArtboards")
|
||||
}
|
||||
|
||||
/// The options the dialog shows: arrays and objects are for agents, and a page range replaces the
|
||||
/// single artboard choice; none for a format with its own dialog.
|
||||
fn shown(f: &Format) -> impl Iterator<Item = &'static FormatOption> {
|
||||
let range = f.options.iter().any(|o| o.name == "range");
|
||||
let separate = separates(f);
|
||||
let options = if own_dialog(f) { &[][..] } else { f.options };
|
||||
options.iter().filter(move |o| !(matches!(o.ty, "array" | "object") || (range && o.name == "artboard")))
|
||||
options.iter().filter(move |o| !(matches!(o.ty, "array" | "object") || (range && o.name == "artboard") || (separate && o.name == "range")))
|
||||
}
|
||||
|
||||
/// An option's label: the save options as the reference app's options dialog names them, the
|
||||
/// others from their names.
|
||||
fn label(o: &FormatOption) -> String {
|
||||
match o.name {
|
||||
"separateArtboards" => "Save each artboard to a separate file".into(),
|
||||
"includeLinked" => "Include Linked Files".into(),
|
||||
"embedProfiles" => "Embed ICC Profiles".into(),
|
||||
"pdfCompatible" => "Create PDF-Compatible File".into(),
|
||||
"compress" => "Use Compression".into(),
|
||||
_ => form::humanize(o.name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every artboard (the All choice under Save each artboard to a separate file): `all` when set,
|
||||
/// else no range given.
|
||||
fn all_artboards(d: &Dialog) -> bool {
|
||||
d.fields.get("all").and_then(Value::as_bool).unwrap_or_else(|| d.str("range").trim().is_empty())
|
||||
}
|
||||
|
||||
/// All or Range, under Save each artboard to a separate file (enabled while it is on).
|
||||
fn artboard_range(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.label("");
|
||||
ui.add_enabled_ui(d.bool("separateArtboards"), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(22.0);
|
||||
let mut all = all_artboards(d);
|
||||
if ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed() {
|
||||
d.fields.insert("all".into(), json!(all));
|
||||
}
|
||||
let mut range = d.str("range");
|
||||
let field = egui::TextEdit::singleline(&mut range).desired_width(90.0).hint_text("1-3, 5");
|
||||
if ui.add_enabled(!all, field).changed() {
|
||||
d.fields.insert("range".into(), json!(range));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// The Save As format menu (labels in [`SAVE_FORMATS`] order).
|
||||
@@ -92,7 +141,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
/// One option: a checkbox, an artboard menu, a number or a text field.
|
||||
fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &FormatOption) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let label = form::humanize(o.name);
|
||||
let label = label(o);
|
||||
let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null);
|
||||
if o.ty == "boolean" {
|
||||
ui.label("");
|
||||
@@ -101,6 +150,9 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma
|
||||
d.fields.insert(o.name.into(), json!(on));
|
||||
}
|
||||
ui.end_row();
|
||||
if o.name == "separateArtboards" {
|
||||
artboard_range(ui, d);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if o.ty == "string" {
|
||||
@@ -154,11 +206,20 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
|
||||
return Err("name the file".into());
|
||||
}
|
||||
let path = if d.bool("__pick") { with_extension(path.trim(), f) } else { path };
|
||||
let options: Map<String, Value> = f
|
||||
let mut options: Map<String, Value> = f
|
||||
.options
|
||||
.iter()
|
||||
.filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone())))
|
||||
.collect();
|
||||
if separates(f) {
|
||||
if !d.bool("separateArtboards") || all_artboards(d) {
|
||||
options.remove("range");
|
||||
} else {
|
||||
// A bad range keeps the dialog open.
|
||||
let n = app.session.active().map_or(0, |s| s.doc.artboards.len());
|
||||
ArtboardPick { range: Some(d.str("range")), ..Default::default() }.resolve(n)?;
|
||||
}
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
match SaveMode::of(&d.str("__action")) {
|
||||
Some(mode) if own_dialog(f) => io::ask_format_options(app, mode, f, &path),
|
||||
|
||||
@@ -194,8 +194,8 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result<SavePlan, Str
|
||||
/// When no path is known, desktop asks with a save panel (one file type per save format) and the
|
||||
/// web opens the Save As dialog (file name and format). With `ask_options` and no options in `p`,
|
||||
/// a format picked that way that has options asks for them before anything is written (SVG
|
||||
/// Options, the Save PDF dialog, or the save options dialog), and so does Save As or Save a Copy
|
||||
/// to a given SVG path. → `{path, format, warnings…}` once written, or `{pending: <dialog kind>,
|
||||
/// Options, the Save PDF dialog, or the save options dialog: native and `.ai` files on Save As
|
||||
/// only), and so does Save As or Save a Copy to a given SVG path. → `{path, format, warnings…}` once written, or `{pending: <dialog kind>,
|
||||
/// path?}` while a dialog is open.
|
||||
pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result<Value, String> {
|
||||
remember_view(app);
|
||||
@@ -222,14 +222,25 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
|
||||
o.insert("path".into(), json!(with_save_extension(&picked, first.format)));
|
||||
}
|
||||
let chosen = plan(app, mode, &q)?;
|
||||
// Native saves don't stop to ask: Use Compression decides, and options can be passed.
|
||||
if ask && !chosen.format.options.is_empty() && !matches!(chosen.format.id, "vectorcraft" | "template") {
|
||||
if ask && asks_options(mode, chosen.format) {
|
||||
let path = chosen.path.clone().unwrap_or_default();
|
||||
return ask_format_options(app, mode, chosen.format, &path);
|
||||
}
|
||||
write_plan(app, chosen)
|
||||
}
|
||||
|
||||
/// Does a save (`mode`) to a picked file of format `f` ask for its options first? Native and `.ai`
|
||||
/// files only on Save As (the options dialog after the save panel); Save, a Copy and templates
|
||||
/// reuse the remembered options or Use Compression.
|
||||
fn asks_options(mode: SaveMode, f: &Format) -> bool {
|
||||
!f.options.is_empty()
|
||||
&& match f.id {
|
||||
"vectorcraft" | "ai" => mode == SaveMode::SaveAs,
|
||||
"template" => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `p` give format options (`options`, `svg: {…}` or SVG options at its top level)?
|
||||
fn has_options(p: &Value) -> bool {
|
||||
["options", "svg"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) || fileio::svg_options(p).is_ok_and(|m| !m.is_empty())
|
||||
|
||||
@@ -47,6 +47,8 @@ mod tests_docsetup;
|
||||
#[cfg(test)]
|
||||
mod tests_labels;
|
||||
#[cfg(test)]
|
||||
mod tests_nativeoptions;
|
||||
#[cfg(test)]
|
||||
mod tests_overprint;
|
||||
#[cfg(test)]
|
||||
mod tests_paintchips;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Save As .ai in the app: a PDF-compatible file written without an options dialog, which reopens
|
||||
//! as the document and saves as .ai again; Save a Copy as .ai leaves the document's path alone.
|
||||
//! Save As .ai in the app: the options dialog, then a PDF-compatible file, which reopens as the
|
||||
//! document and saves as .ai again (without asking); Save a Copy as .ai leaves the document's path
|
||||
//! alone.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -8,8 +9,13 @@ use crate::tests_svg::app;
|
||||
#[test]
|
||||
fn save_as_ai_writes_a_pdf_that_reopens_editable() {
|
||||
let (mut app, written) = app("/tmp/art.ai");
|
||||
// Save As asks for the .ai options first (M4.38), then writes.
|
||||
let r = app.run("file.saveAs", Value::Null).unwrap();
|
||||
assert_eq!(r["path"], "/tmp/art.ai", "no options dialog: {r}");
|
||||
assert_eq!(r["pending"], "saveOptions", "{r}");
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.str("format").as_str(), d.str("path").as_str(), d.bool("pdfCompatible")), ("ai", "/tmp/art.ai", true));
|
||||
let r = crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(r["path"], "/tmp/art.ai", "{r}");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let ai = written.borrow()[0].1.clone();
|
||||
assert!(ai.starts_with(b"%PDF"));
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! VectorCraft Options after Save As: each artboard to a separate file (All or a range), Include
|
||||
//! Linked Files, Embed ICC Profiles, Create PDF-Compatible File, Use Compression and Preview; the
|
||||
//! web's Save As dialog shows them too.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tests_svg::app;
|
||||
use crate::{dialogs, theme};
|
||||
|
||||
/// One headless frame of the dialog layer.
|
||||
fn frame(app: &mut crate::VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Add artboards B and C, a rectangle on each.
|
||||
fn three_boards(app: &mut crate::VectorcraftApp) {
|
||||
for (x, name) in [(200, "B"), (400, "C")] {
|
||||
app.run("artboard.new", json!({"x": x, "y": 0, "width": 100, "height": 80, "name": name})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": x + 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_native_asks_for_its_options_and_writes_every_artboard() {
|
||||
let (mut app, written) = app("/tmp/doc.vectorcraft");
|
||||
three_boards(&mut app);
|
||||
let r = app.run("file.saveAs", Value::Null).unwrap();
|
||||
assert_eq!(r["pending"], "saveOptions");
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(d.str("format").as_str(), d.bool("separateArtboards"), d.bool("embedProfiles"), d.bool("pdfCompatible")),
|
||||
("vectorcraft", false, true, false)
|
||||
);
|
||||
assert!(written.borrow().is_empty(), "nothing is written before OK");
|
||||
frame(&mut app);
|
||||
assert!(app.ui.dialog.is_some(), "drawing keeps it open");
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("separateArtboards".into(), json!(true));
|
||||
d.fields.insert("all".into(), json!(false));
|
||||
d.fields.insert("range".into(), json!("9"));
|
||||
frame(&mut app);
|
||||
assert!(dialogs::confirm(&mut app).is_err(), "a bad range keeps the dialog open");
|
||||
assert!(app.ui.dialog.is_some());
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("range".into(), json!("2-3"));
|
||||
let r = dialogs::confirm(&mut app).unwrap();
|
||||
let names: Vec<String> = written.borrow().iter().map(|(p, _)| p.clone()).collect();
|
||||
assert_eq!(names, ["/tmp/doc.vectorcraft", "/tmp/doc-B.vectorcraft", "/tmp/doc-C.vectorcraft"], "{r}");
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((st.path.as_deref(), st.is_dirty()), (Some("/tmp/doc.vectorcraft"), false));
|
||||
// Save writes them again with the same options, without asking.
|
||||
app.run("shape.ellipse", json!({"x": 50, "y": 40, "width": 10, "height": 10})).unwrap();
|
||||
app.run("file.save", Value::Null).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(written.borrow().len(), 6);
|
||||
// All: every artboard.
|
||||
app.run("file.saveAs", Value::Null).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert!(d.bool("separateArtboards"), "the options as last saved");
|
||||
d.fields.insert("all".into(), json!(true));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(written.borrow().len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_a_copy_and_save_dont_ask_and_options_reach_the_file() {
|
||||
let (mut app, written) = app("/tmp/plain.vectorcraft");
|
||||
// Save of an untitled document goes straight to the file, as before.
|
||||
app.run("file.save", Value::Null).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(written.borrow().len(), 1);
|
||||
app.run("file.saveAs", Value::Null).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("pdfCompatible".into(), json!(true));
|
||||
d.fields.insert("compress".into(), json!(false));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
let bytes = written.borrow()[1].1.clone();
|
||||
assert!(vectorcraft_format::pdf_content(&bytes).is_some_and(|p| p.starts_with(b"%PDF")));
|
||||
assert!(!vectorcraft_format::is_compressed(&bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_web_save_as_dialog_shows_the_native_options() {
|
||||
let written = crate::tests_svg::Written::default();
|
||||
let w = written.clone();
|
||||
let services = crate::Services {
|
||||
download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), services);
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
three_boards(&mut app);
|
||||
app.run("file.saveAs", Value::Null).unwrap();
|
||||
frame(&mut app);
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert!(d.bool("__pick") && d.fields.contains_key("separateArtboards"));
|
||||
d.fields.insert("separateArtboards".into(), json!(true));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
let names: Vec<String> = written.borrow().iter().map(|(p, _)| p.clone()).collect();
|
||||
assert_eq!(names, ["Untitled-1.vectorcraft", "Untitled-1-Artboard-1.vectorcraft", "Untitled-1-B.vectorcraft", "Untitled-1-C.vectorcraft"]);
|
||||
}
|
||||
@@ -346,3 +346,9 @@ saved}]`, from `file.recovery.list`) and `discard`. `ui.dialog.confirm` restores
|
||||
each, opening "<name> [Recovered]" documents), `discard: true` then confirm deletes them, `ui.dialog.cancel` keeps them
|
||||
for the next launch. While copies are written in the background `ui.inspect` lists them under `background`
|
||||
("Saving recovery data for <name>").
|
||||
|
||||
Native save options: File → Save As to a native or `.ai` file opens the `saveOptions` dialog after the save panel
|
||||
(heading "VectorCraft Options" or "PDF-compatible .ai Options"). Fields: `separateArtboards`, `all` (true: every
|
||||
artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for
|
||||
native files `version` and `preview`. A bad range keeps the dialog open; `ui.dialog.confirm` writes the file and the
|
||||
artboards' files.
|
||||
+23
@@ -1291,3 +1291,26 @@ folder or browser storage the commands are disabled and say how to set one.
|
||||
{"name":"run_command","arguments":{"command":"file.recovery.save","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"file.recovery.restore","params":{"file":"1759650000-1/Poster-1"}}}
|
||||
```
|
||||
|
||||
## Native save options
|
||||
|
||||
`document.save` / `file.saveAs` / `file.saveCopy` to a native (`.vectorcraft`, `.vctemplate`) or `.ai` file take, flat
|
||||
or in `options` (`file.formatOptions {format}` lists them with their values):
|
||||
|
||||
- `separateArtboards: true` also writes each artboard of `range` (`"1-3, 5"`, default `"all"`) to
|
||||
`<name>-<artboard>.<ext>` beside the file: that artboard and the art touching it. The result's `files` lists every
|
||||
file written, the master file first (without a path: `files: [{name, dataBase64}]`). 3 artboards give 4 files.
|
||||
- `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews.
|
||||
- `embedProfiles` (default true) carries the ICC profiles the document is tagged with that were loaded from files;
|
||||
opening the file installs them where they are missing (a warning names one that can't be used).
|
||||
- `pdfCompatible`: native files (default false) also carry a PDF of every artboard (`pdf` in the file); `.ai` files
|
||||
(default true) with false write blank pages around the native document (smaller; other apps show empty pages).
|
||||
- `.ai` only: `compress` (default true) is the PDF option `compression.compressText`.
|
||||
|
||||
File → Save As asks for these in the save options dialog (VectorCraft Options) after the save panel; Save, Save a Copy
|
||||
and Save as Template reuse the options as last saved (or Use Compression).
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.vectorcraft","separateArtboards":true,"range":"2-3","includeLinked":true}}}
|
||||
{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.ai","pdfCompatible":false}}}
|
||||
```
|
||||
Reference in new issue
Block a user