mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
M4.35: native files compress, save atomically, drop unused images, keep newer keys and save down to v1
- Format v3: optional gzip (Preferences > File Handling > Use Compression, or
document.save {compress}); compressed files open by their magic bytes, legacy
ones too.
- Every file write goes to a temporary file renamed over the target (engine
writes and the desktop app's writer), so a failed save keeps the old file.
- Only the images the document uses are written.
- Document keys from newer versions are kept (Document.extra, flattened).
- Assigned colour profiles are a real field (Document.color_profiles),
migrated from unknown.colorProfiles on load.
- document.save {version: 1|2} writes files older versions open; {preview}
embeds a PNG of the first artboard of at most 256 px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7aee3bd487
commit
65cdc45ed9
@@ -4,7 +4,8 @@
|
||||
//!
|
||||
//! Colour settings and the proof view are process-wide (`vectorcraft_color::cms::active`,
|
||||
//! `vectorcraft_render::proof::view`). A document's assigned profiles are stored in
|
||||
//! `Document.unknown["colorProfiles"]` (`{rgb?, cmyk?}`) and become active when assigned.
|
||||
//! `Document::color_profiles` (files before format v3: `Document.unknown["colorProfiles"]`) and
|
||||
//! become active when assigned.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -16,8 +17,9 @@ use vectorcraft_render::proof;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `Document.unknown` key for the assigned profiles.
|
||||
pub const PROFILES_KEY: &str = "colorProfiles";
|
||||
/// `Document.unknown` key under which files before format v3 kept the assigned profiles (migrated
|
||||
/// to `Document::color_profiles` on load).
|
||||
pub const PROFILES_KEY: &str = vectorcraft_doc::profiles::LEGACY_PROFILES_KEY;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -188,9 +190,8 @@ fn load_profile(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
/// The document's assigned profiles (`None` = working space).
|
||||
pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option<String>, Option<String>) {
|
||||
let o = d.unknown.get(PROFILES_KEY);
|
||||
let get = |k: &str| o.and_then(|o| o.get(k)).and_then(Value::as_str).map(|n| cms::canonical_name(n).to_string());
|
||||
(get("rgb"), get("cmyk"))
|
||||
let get = |n: &Option<String>| n.as_deref().map(|n| cms::canonical_name(n).to_string());
|
||||
(get(&d.color_profiles.rgb), get(&d.color_profiles.cmyk))
|
||||
}
|
||||
|
||||
fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -213,13 +214,9 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let changed = pick("rgb", &mut rgb, cms::ProfileKind::Rgb)? | pick("cmyk", &mut cmyk, cms::ProfileKind::Cmyk)?;
|
||||
if changed {
|
||||
let (r, c) = (rgb.clone(), cmyk.clone());
|
||||
let profiles = vectorcraft_doc::ColorProfiles { rgb: rgb.clone(), cmyk: cmyk.clone() };
|
||||
s.edit("Assign Profile", |d, _| {
|
||||
if r.is_none() && c.is_none() {
|
||||
d.unknown.remove(PROFILES_KEY);
|
||||
} else {
|
||||
d.unknown.insert(PROFILES_KEY.into(), json!({"rgb": r, "cmyk": c}));
|
||||
}
|
||||
d.color_profiles = profiles;
|
||||
Ok(())
|
||||
})?;
|
||||
let mut st = cms::active_settings();
|
||||
|
||||
@@ -250,7 +250,7 @@ fn boards<T>(r: std::result::Result<T, String>) -> Result<T> {
|
||||
r.map_err(|e| bad(C, e))
|
||||
}
|
||||
|
||||
fn options<T: DeserializeOwned + Default>(f: &Format, p: &Value) -> Result<T> {
|
||||
pub(super) fn options<T: DeserializeOwned + Default>(f: &Format, p: &Value) -> Result<T> {
|
||||
if !p.is_object() {
|
||||
return Ok(T::default());
|
||||
}
|
||||
@@ -303,12 +303,12 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
return encode_all(&single_artboard(doc, bounds, "Art"), f.id, &q);
|
||||
}
|
||||
let bytes = match f.id {
|
||||
"vectorcraft" => vectorcraft_format::save_file(doc),
|
||||
"vectorcraft" => super::native::encode(C, f, doc, p)?,
|
||||
// A native file flagged so opening it starts a new untitled document.
|
||||
"template" => {
|
||||
let mut d = doc.clone();
|
||||
d.template = true;
|
||||
vectorcraft_format::save_file(&d)
|
||||
super::native::encode(C, f, &d, p)?
|
||||
}
|
||||
"txt" => super::text::encode(doc, p)?,
|
||||
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
|
||||
|
||||
@@ -20,6 +20,7 @@ mod encode;
|
||||
mod export;
|
||||
mod imagemap;
|
||||
mod load;
|
||||
mod native;
|
||||
pub mod pdf;
|
||||
mod pdfimport;
|
||||
pub mod pngtext;
|
||||
@@ -38,9 +39,14 @@ use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
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;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub use save::{SAVE_FORMATS, SaveMode, SavePlan, save_filters, save_format, save_plan, save_with, stamp_save_dates, templates_folder};
|
||||
pub use svg::options_map as svg_options;
|
||||
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
|
||||
/// file it replaces), for the apps' own writers too.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use vectorcraft_format::write_atomic;
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -320,7 +326,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[],
|
||||
options: native::OPTIONS,
|
||||
},
|
||||
Format { id: "svg", label: "SVG", extensions: &["svg"], mime: "image/svg+xml", read: true, write: true, raster: false, options: svg::OPTIONS },
|
||||
Format {
|
||||
@@ -392,7 +398,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[],
|
||||
options: native::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 },
|
||||
@@ -488,7 +494,8 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
// Never a half-written file: see [`write_atomic`].
|
||||
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The native format's options: compression, the version to write (older apps read older
|
||||
//! versions) and an embedded preview.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_format::{PREVIEW_MAX, SaveOptions};
|
||||
|
||||
use super::super::*;
|
||||
use super::{Format, FormatOption};
|
||||
|
||||
/// The options `document.save` and the native encoder read.
|
||||
pub const OPTIONS: &[FormatOption] = &[
|
||||
FormatOption {
|
||||
name: "compress",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "gzip the file: smaller, not readable as text (saves default to Preferences → File Handling → Use Compression)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "version",
|
||||
ty: "integer",
|
||||
default: "3",
|
||||
description: "the format version to write: 3 (current), or 2 or 1 for older VectorCraft versions (not compressed; newer features they don't know are lost there)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "preview",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "embed a PNG preview of the first artboard (at most 256 pixels on its longer side) for file browsers",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct NativeOptions {
|
||||
compress: Option<bool>,
|
||||
version: Option<u32>,
|
||||
preview: bool,
|
||||
}
|
||||
|
||||
/// 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 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);
|
||||
if o.preview {
|
||||
so.preview = preview_png(doc)?;
|
||||
}
|
||||
vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing
|
||||
/// to show).
|
||||
fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
|
||||
let Some(r) = doc.artboards.first().map(|a| a.rect).or_else(|| vectorcraft_render::encode::art_bounds(doc)) else { return Ok(None) };
|
||||
let scale = f64::from(PREVIEW_MAX) / r.width().max(r.height());
|
||||
if vectorcraft_render::raster_size(r, scale).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
vectorcraft_render::Renderer::new().render_region(doc, r, scale, false).to_png().map(Some).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
/// `p` with `compress` from the Use Compression preference when it doesn't say (saves only;
|
||||
/// exports and serializing write what they're told).
|
||||
pub fn with_compression_pref(prefs: &crate::Prefs, p: &Value) -> Value {
|
||||
let mut q = if p.is_object() { p.clone() } else { Value::Object(Default::default()) };
|
||||
if let Some(o) = q.as_object_mut()
|
||||
&& o.get("compress").is_none_or(Value::is_null)
|
||||
// Older versions can't read compressed files: those saves stay plain.
|
||||
&& o.get("version").and_then(Value::as_u64).is_none_or(|v| v >= u64::from(vectorcraft_format::COMPRESSED_SINCE))
|
||||
{
|
||||
o.insert("compress".into(), Value::Bool(prefs.use_compression));
|
||||
}
|
||||
q
|
||||
}
|
||||
@@ -12,7 +12,9 @@ use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
use super::super::*;
|
||||
use super::{Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, write_encoded, write_file};
|
||||
use super::{
|
||||
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};
|
||||
|
||||
/// The formats Save As offers, in menu order (append-only). The other writable formats are exports:
|
||||
@@ -152,8 +154,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
let mut svg = super::svg_options(&Value::Object(given)).map_err(|e| bad(cmd, e))?;
|
||||
svg.extend(super::svg_options(p).map_err(|e| bad(cmd, e))?);
|
||||
given = svg;
|
||||
} else if is_pdf(format) {
|
||||
// So do PDF options (as the Save PDF dialog and document.exportPdf name them).
|
||||
} else {
|
||||
// So do PDF options (as the Save PDF dialog and document.exportPdf name them) and the
|
||||
// native ones (compress, version, preview).
|
||||
if let Some(o) = p.as_object() {
|
||||
given.extend(
|
||||
o.iter().filter(|(k, v)| !v.is_null() && k.as_str() != "path" && reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())),
|
||||
@@ -247,7 +250,11 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[
|
||||
// A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out.
|
||||
params.entry("hiddenLayers").or_insert(Value::Bool(true));
|
||||
}
|
||||
let params = Value::Object(params);
|
||||
let mut params = Value::Object(params);
|
||||
if matches!(plan.format.id, "vectorcraft" | "template") {
|
||||
// Not remembered with the options: the preference decides each time it isn't given.
|
||||
params = with_compression_pref(&s.prefs, ¶ms);
|
||||
}
|
||||
let params = super::pdf::expand_preset(s, cmd, ¶ms)?;
|
||||
let mut enc = if plan.format.id == "ai" {
|
||||
// A PDF of every artboard with the PDF options, always carrying the native document.
|
||||
@@ -328,10 +335,13 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 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!({}));
|
||||
let mut v = f.to_json();
|
||||
if let Some(options) = v["options"].as_object_mut() {
|
||||
for (name, o) in options.iter_mut() {
|
||||
o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(&o["default"]).clone();
|
||||
let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]);
|
||||
o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(default).clone();
|
||||
}
|
||||
}
|
||||
v["saveFormats"] =
|
||||
@@ -346,7 +356,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), 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, 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), 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, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
@@ -373,7 +383,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
"{path?, compress?, version?, preview? (as document.save)} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Template, p)
|
||||
),
|
||||
|
||||
@@ -318,6 +318,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
// File Handling (continued)
|
||||
p!("templatesFolder", "File Handling", "Files", "Templates Folder", text),
|
||||
p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool),
|
||||
// Native saves (`document.save {compress}`)
|
||||
p!("useCompression", "File Handling", "Files", "Use Compression", bool),
|
||||
];
|
||||
|
||||
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
|
||||
|
||||
Reference in New Issue
Block a user