717 lines
31 KiB
Rust
717 lines
31 KiB
Rust
//! The native `.vectorcraft` format.
|
|
//!
|
|
//! A `.vectorcraft` file is UTF-8 JSON, gzip-compressed when saved with Use Compression (told apart
|
|
//! by its magic bytes on open, whatever the version):
|
|
//! ```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. `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
|
|
//! profiles in `document.color_profiles` (before: `document.unknown.colorProfiles`) and may be
|
|
//! compressed; v4 may keep large data (images, placed documents, the PDF, profiles of at least
|
|
//! [`INLINE_MAX`] bytes) after the JSON instead of in it: the JSON is followed by [`BLOB_MAGIC`] and
|
|
//! the bytes, and such an entry says where they are (`"blob": [offset, length]`) instead of giving
|
|
//! `data`. Every version loads, and [`save_with`] writes any of them for older apps (under the
|
|
//! format name `drawcraft`, which every version reads). Readers reject files whose `version` is
|
|
//! newer than they support.
|
|
#![forbid(unsafe_code)]
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
mod atomic;
|
|
mod legacy;
|
|
mod stream;
|
|
mod unpack;
|
|
|
|
use std::borrow::Cow;
|
|
use std::collections::{BTreeMap, HashSet};
|
|
use std::io::{Read as _, Write};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use vectorcraft_doc::{Document, ImageBlob};
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with};
|
|
use stream::{Gz, Indent, KeyScan, Out};
|
|
|
|
/// v4: large data after the JSON (v1 to v3 files still load).
|
|
pub const VERSION: u32 = 4;
|
|
/// Data this large or larger goes after the JSON (v4), not into it as base64.
|
|
pub const INLINE_MAX: usize = 8 << 10;
|
|
/// What separates the JSON from the data after it (v4).
|
|
pub const BLOB_MAGIC: &[u8] = b"\n\0VCBLOBS\0";
|
|
/// The oldest version [`save_with`] writes.
|
|
pub const MIN_VERSION: u32 = 1;
|
|
/// The first version whose readers open compressed files.
|
|
pub const COMPRESSED_SINCE: u32 = 3;
|
|
/// Most pixels on the longer side of an embedded preview.
|
|
pub const PREVIEW_MAX: u32 = 256;
|
|
pub const EXTENSION: &str = "vectorcraft";
|
|
/// Extension and format name from before the project was renamed (DrawCraft): still opened.
|
|
pub const LEGACY_EXTENSION: &str = "drawcraft";
|
|
/// Most bytes the JSON of a compressed file may unpack to (a guard against decompression bombs,
|
|
/// as are [`MAX_BLOB`] and [`MAX_BLOBS`]). A compressed save refuses a document past any of them,
|
|
/// so every file VectorCraft writes reads back.
|
|
pub const MAX_JSON: u64 = 1 << 30;
|
|
/// Most bytes one entry of the data after the JSON (an image, a placed document, the PDF, a
|
|
/// profile) may have in a compressed file.
|
|
pub const MAX_BLOB: u64 = 1 << 30;
|
|
/// Most bytes the entries after the JSON may have together in a compressed file.
|
|
pub const MAX_BLOBS: u64 = 4 << 30;
|
|
/// Most bytes a compressed file whose JSON isn't followed by the marker where VectorCraft puts it
|
|
/// (hand-made or damaged files) may unpack to: it is unpacked whole.
|
|
const MAX_UNPACKED: u64 = 512 << 20;
|
|
|
|
/// Is `ext` (without the dot, any case) a native document extension?
|
|
pub fn is_native_ext(ext: &str) -> bool {
|
|
ext.eq_ignore_ascii_case(EXTENSION) || ext.eq_ignore_ascii_case(LEGACY_EXTENSION)
|
|
}
|
|
|
|
/// Does the file name or path end in a native document extension?
|
|
pub fn is_native_name(name: &str) -> bool {
|
|
std::path::Path::new(name).extension().and_then(|e| e.to_str()).is_some_and(is_native_ext)
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum FormatError {
|
|
#[error("not a VectorCraft file: {0}")]
|
|
NotVectorcraft(String),
|
|
#[error("file version {0} is newer than this VectorCraft supports ({VERSION})")]
|
|
TooNew(u32),
|
|
#[error("invalid image data for `{0}`")]
|
|
BadImage(String),
|
|
#[error("can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION}")]
|
|
BadVersion(u32),
|
|
#[error("version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files")]
|
|
CompressedTooOld(u32),
|
|
#[error("the compressed file unpacks to more than VectorCraft reads ({} MB of document, {} MB per image)", MAX_JSON >> 20, MAX_BLOB >> 20)]
|
|
TooBig,
|
|
#[error("the document is too large to save compressed ({0}): save it with Use Compression off")]
|
|
TooBigToCompress(String),
|
|
#[error("can't encode the document: {0}")]
|
|
Encode(String),
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct Image {
|
|
mime: String,
|
|
/// The bytes as base64, unless they come after the JSON (`blob`).
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
data: String,
|
|
/// Where the bytes are in the data after the JSON (v4): `[offset, length]`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
blob: Option<[u64; 2]>,
|
|
/// `data` is a linked image's preview ([`ImageBlob::proxy`]), not the file's bytes.
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
proxy: bool,
|
|
}
|
|
|
|
impl Image {
|
|
/// Its bytes when they are in the JSON (base64 `data`); `None` when they come after it
|
|
/// ([`Image::blob`]) or don't decode.
|
|
fn inline(&self) -> Option<Vec<u8>> {
|
|
match self.blob {
|
|
Some(_) => None,
|
|
None => base64_decode(&self.data),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Entries written for one file: large bytes go to the data after the JSON when the version keeps
|
|
/// them there (in the order they are added, each at the offset it is given).
|
|
struct Blobs<'a> {
|
|
/// The bytes, and whether they are compressed already ([`is_packed`]).
|
|
tail: Vec<(&'a [u8], bool)>,
|
|
at: u64,
|
|
outside: bool,
|
|
}
|
|
|
|
impl<'a> Blobs<'a> {
|
|
fn image(&mut self, mime: &str, bytes: &'a [u8], proxy: bool) -> Image {
|
|
if self.outside && bytes.len() >= INLINE_MAX {
|
|
let len = bytes.len() as u64;
|
|
let at = self.at;
|
|
self.at += len;
|
|
self.tail.push((bytes, is_packed(mime)));
|
|
return Image { mime: mime.into(), data: String::new(), blob: Some([at, len]), proxy };
|
|
}
|
|
Image { mime: mime.into(), data: base64_encode(bytes), blob: None, proxy }
|
|
}
|
|
}
|
|
|
|
/// Is data of this MIME type compressed already (no use compressing it again)?
|
|
fn is_packed(mime: &str) -> bool {
|
|
matches!(mime, "image/jpeg" | "image/png" | "image/gif" | "image/webp")
|
|
}
|
|
|
|
/// The MIME type of a preview's bytes (JPEG or PNG).
|
|
fn preview_mime(bytes: &[u8]) -> &'static str {
|
|
if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME }
|
|
}
|
|
|
|
/// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON
|
|
/// value.
|
|
fn split_tail(text: &[u8]) -> (&[u8], &[u8]) {
|
|
// JSON holds no raw NUL, so the marker (which has one) starts at the first NUL, if the file
|
|
// has one. The writer puts it right after the JSON's last byte; found there, it is where the
|
|
// first JSON value ends, without parsing the JSON twice. Anything else takes the parse.
|
|
let Some(nul) = text.iter().position(|&b| b == 0) else {
|
|
// No NUL, so no marker: the parse below would find none either.
|
|
return (text, &[]);
|
|
};
|
|
if let Some(at) = nul.checked_sub(1)
|
|
&& text.get(at..).is_some_and(|r| r.starts_with(BLOB_MAGIC))
|
|
&& at.checked_sub(1).and_then(|k| text.get(k)).is_some_and(|b| matches!(b, b'}' | b']' | b'"'))
|
|
{
|
|
return (text.get(..at).unwrap_or(text), text.get(at + BLOB_MAGIC.len()..).unwrap_or_default());
|
|
}
|
|
let mut values = serde_json::Deserializer::from_slice(text).into_iter::<serde::de::IgnoredAny>();
|
|
if values.next().is_some_and(|v| v.is_ok()) {
|
|
let end = values.byte_offset();
|
|
if let Some(rest) = text.get(end..)
|
|
&& rest.starts_with(BLOB_MAGIC)
|
|
{
|
|
return (text.get(..end).unwrap_or(text), rest.get(BLOB_MAGIC.len()..).unwrap_or_default());
|
|
}
|
|
}
|
|
(text, &[])
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct File {
|
|
format: String,
|
|
version: u32,
|
|
document: Document,
|
|
#[serde(default)]
|
|
images: BTreeMap<String, Image>,
|
|
#[serde(default)]
|
|
profiles: BTreeMap<String, Image>,
|
|
}
|
|
|
|
/// How [`save_with`] writes a document.
|
|
#[derive(Clone, Debug)]
|
|
pub struct SaveOptions {
|
|
/// Indented JSON (readable, diffable); compact otherwise. Compressed files are always compact.
|
|
pub pretty: bool,
|
|
/// gzip the file (Use Compression), for version [`COMPRESSED_SINCE`] and later.
|
|
pub compress: bool,
|
|
/// The version to write ([`MIN_VERSION`]`..=`[`VERSION`]): older ones for older apps.
|
|
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, 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)?
|
|
pub fn check(&self) -> Result<(), FormatError> {
|
|
if !(MIN_VERSION..=VERSION).contains(&self.version) {
|
|
return Err(FormatError::BadVersion(self.version));
|
|
}
|
|
if self.compress && self.version < COMPRESSED_SINCE {
|
|
return Err(FormatError::CompressedTooOld(self.version));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The defaults for saving `doc` to disk: pretty when small (see [`PRETTY_MAX_OBJECTS`] and
|
|
/// [`PRETTY_MAX_BYTES`]).
|
|
pub fn for_doc(doc: &Document) -> Self {
|
|
let objects: usize = doc.layers.iter().map(|l| l.count()).sum();
|
|
Self { pretty: objects <= PRETTY_MAX_OBJECTS && compact_size_at_most(doc, PRETTY_MAX_BYTES), ..Self::default() }
|
|
}
|
|
}
|
|
|
|
/// Is `doc` at most `limit` bytes as compact JSON? Serializing stops as soon as it is past the
|
|
/// limit, so a large document costs no more than `limit` bytes of work.
|
|
fn compact_size_at_most(doc: &Document, limit: usize) -> bool {
|
|
struct Capped {
|
|
left: usize,
|
|
}
|
|
impl Write for Capped {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
self.left = self.left.checked_sub(buf.len()).ok_or_else(|| std::io::Error::other("over the limit"))?;
|
|
Ok(buf.len())
|
|
}
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
serde_json::to_writer(Capped { left: limit }, doc).is_ok()
|
|
}
|
|
|
|
/// Serialize a document (pretty = human-diffable) in the current version. Editing-mode working
|
|
/// copies are left out ([`Document::without_edit_modes`]).
|
|
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
|
save_with(doc, &SaveOptions { pretty, ..SaveOptions::default() }).unwrap_or_default()
|
|
}
|
|
|
|
/// Documents up to this many objects are saved pretty-printed (readable, diffable); larger ones
|
|
/// compact, where indentation would multiply the file size and slow opening down.
|
|
pub const PRETTY_MAX_OBJECTS: usize = 2000;
|
|
|
|
/// Documents whose compact JSON is larger than this are saved compact even with few objects:
|
|
/// pretty-printing spreads every path anchor over several lines, so traced or generated art (in
|
|
/// layers, symbols or patterns alike) would grow about tenfold.
|
|
pub const PRETTY_MAX_BYTES: usize = 1 << 20;
|
|
|
|
/// Serialize for saving to disk: pretty when small, compact when large.
|
|
pub fn save_file(doc: &Document) -> Vec<u8> {
|
|
save_with(doc, &SaveOptions::for_doc(doc)).unwrap_or_default()
|
|
}
|
|
|
|
/// Serialize a document as `o` says: the version, compression, layout and preview. Editing-mode
|
|
/// working copies and images nothing uses are left out.
|
|
pub fn save_with(doc: &Document, o: &SaveOptions) -> Result<Vec<u8>, FormatError> {
|
|
let mut out = Vec::new();
|
|
save_to(doc, o, &mut out)?;
|
|
Ok(out)
|
|
}
|
|
|
|
/// Bytes of JSON serialized at a time on their way to the output.
|
|
const JSON_CHUNK: usize = 64 << 10;
|
|
|
|
/// [`save_with`], written to `w` as it is made: the JSON is serialized straight into it (through
|
|
/// the compressor when compressing) and the images are copied in from the document, so the file
|
|
/// is never held in memory as a whole. Give it a buffered writer. On an error `w` has part of a
|
|
/// file: write to a temporary file ([`write_atomic_with`]).
|
|
pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), FormatError> {
|
|
o.check()?;
|
|
let pretty = o.pretty && !o.compress;
|
|
let mut d = doc.without_edit_modes().into_owned();
|
|
// Older apps don't know placed documents: they get the art the objects show, with its
|
|
// resources. Otherwise resources only exporting adds are never saved.
|
|
if o.version < VERSION && d.has_placed() {
|
|
d = d.with_placed_art().into_owned();
|
|
d.placed_as_groups();
|
|
} else {
|
|
d.drop_placed_resources();
|
|
}
|
|
let linked = d.linked_only_images();
|
|
// The blobs go in the file's `images` (only those the document uses).
|
|
let blobs = std::mem::take(&mut d.images);
|
|
if o.version < 3 {
|
|
d.store_legacy_color_profiles();
|
|
}
|
|
let v1 = if o.version == 1 {
|
|
let mut v = serde_json::to_value(&d).map_err(encode_err)?;
|
|
legacy::anchors_to_v1(&mut v);
|
|
Some(v)
|
|
} else {
|
|
None
|
|
};
|
|
let entries = Entries { blobs: &blobs, linked: &linked, include_linked: o.include_linked };
|
|
let mut out_blobs = Blobs { tail: vec![], at: 0, outside: o.version >= 4 };
|
|
// Images are only referenced by image objects' `key` (wherever the objects are: layers,
|
|
// symbols, patterns, masks, or foreign data). The data after the JSON has the images first,
|
|
// then the profiles and the PDF, which the file lists before the document: with those, the
|
|
// images in use are looked for before the document is written (it is serialized twice).
|
|
let mut images = None;
|
|
if !blobs.is_empty() && (!o.profiles.is_empty() || o.pdf.is_some()) {
|
|
let mut scan = KeyScan::new(std::io::sink());
|
|
write_body(std::io::BufWriter::with_capacity(JSON_CHUNK, &mut scan), v1.as_ref(), &d, pretty)?;
|
|
images = Some(entries.images(&scan.keys, &mut out_blobs));
|
|
}
|
|
let profiles: BTreeMap<&str, Image> = o.profiles.iter().map(|(name, icc)| (name.as_str(), out_blobs.image(ICC_MIME, icc, false))).collect();
|
|
let pdf = o.pdf.as_deref().map(|p| out_blobs.image("application/pdf", p, false));
|
|
let blob = |mime: &str, bytes: &[u8]| Image { mime: mime.into(), data: base64_encode(bytes), blob: None, proxy: false };
|
|
let mut out = if o.compress { Out::Gz(Box::new(Gz::new(w).map_err(encode_err)?)) } else { Out::Plain(w) };
|
|
let mut env = Envelope { pretty, first: true };
|
|
// Older apps only know the name from before the rename.
|
|
env.field(&mut out, "format", &if o.version < 3 { LEGACY_EXTENSION } else { EXTENSION })?;
|
|
env.field(&mut out, "version", &o.version)?;
|
|
env.field(&mut out, "generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?;
|
|
if let Some(png) = &o.preview {
|
|
env.field(&mut out, "preview", &blob("image/png", png))?;
|
|
}
|
|
if !profiles.is_empty() {
|
|
env.field(&mut out, "profiles", &profiles)?;
|
|
}
|
|
if let Some(pdf) = &pdf {
|
|
env.field(&mut out, "pdf", pdf)?;
|
|
}
|
|
env.key(&mut out, "document")?;
|
|
let mut scan = KeyScan::new(Indent { inner: &mut out, on: pretty });
|
|
// Without blobs no key matters.
|
|
scan.active = images.is_none() && !blobs.is_empty();
|
|
let mut buffered = std::io::BufWriter::with_capacity(JSON_CHUNK, &mut scan);
|
|
write_body(&mut buffered, v1.as_ref(), &d, pretty)?;
|
|
buffered.flush().map_err(encode_err)?;
|
|
drop(buffered);
|
|
let used = std::mem::take(&mut scan.keys);
|
|
let images = images.unwrap_or_else(|| entries.images(&used, &mut out_blobs));
|
|
env.field(&mut out, "images", &images)?;
|
|
env.finish(&mut out)?;
|
|
if o.compress {
|
|
let json = out.written().unwrap_or(0);
|
|
let tail: Vec<u64> = out_blobs.tail.iter().map(|(b, _)| b.len() as u64).collect();
|
|
if json > MAX_JSON {
|
|
return Err(FormatError::TooBigToCompress(format!("{} MB of document, at most {} MB", json >> 20, MAX_JSON >> 20)));
|
|
}
|
|
if let Some(big) = tail.iter().find(|&&n| n > MAX_BLOB) {
|
|
return Err(FormatError::TooBigToCompress(format!("an image of {} MB, at most {} MB", big >> 20, MAX_BLOB >> 20)));
|
|
}
|
|
if out_blobs.at > MAX_BLOBS {
|
|
return Err(FormatError::TooBigToCompress(format!("{} MB of images, at most {} MB", out_blobs.at >> 20, MAX_BLOBS >> 20)));
|
|
}
|
|
}
|
|
if !out_blobs.tail.is_empty() {
|
|
out.write_all(BLOB_MAGIC).map_err(encode_err)?;
|
|
for &(bytes, packed) in &out_blobs.tail {
|
|
out.packed(packed).map_err(encode_err)?;
|
|
out.write_all(bytes).map_err(encode_err)?;
|
|
}
|
|
}
|
|
out.finish().map_err(encode_err)
|
|
}
|
|
|
|
/// The images a file lists, from the document's blobs.
|
|
struct Entries<'a> {
|
|
blobs: &'a BTreeMap<String, ImageBlob>,
|
|
/// Images only linked images show (see [`Document::linked_only_images`]).
|
|
linked: &'a std::collections::BTreeSet<String>,
|
|
include_linked: bool,
|
|
}
|
|
|
|
impl<'a> Entries<'a> {
|
|
/// The entries of the images named in `used`, in the order of their names.
|
|
fn images(&self, used: &HashSet<String>, out: &mut Blobs<'a>) -> BTreeMap<&'a str, Image> {
|
|
self.blobs
|
|
.iter()
|
|
.filter(|(k, _)| used.contains(k.as_str()))
|
|
.map(|(k, b)| {
|
|
// Include Linked Files keeps the file's bytes whenever they are loaded.
|
|
let preview_only = self.linked.contains(k) && (!self.include_linked || b.is_proxy());
|
|
let image = match b.proxy.as_ref().filter(|_| preview_only) {
|
|
Some(p) => out.image(preview_mime(p), p, true),
|
|
None => out.image(&b.mime, &b.bytes, false),
|
|
};
|
|
(k.as_str(), image)
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
fn encode_err(e: impl std::fmt::Display) -> FormatError {
|
|
FormatError::Encode(e.to_string())
|
|
}
|
|
|
|
fn to_json(v: &impl Serialize, pretty: bool) -> Result<Vec<u8>, FormatError> {
|
|
if pretty { serde_json::to_vec_pretty(v) } else { serde_json::to_vec(v) }.map_err(encode_err)
|
|
}
|
|
|
|
/// The document's JSON: its version 1 form `v1`, else `d` (generic, not `dyn Write`: serde_json
|
|
/// writes token by token).
|
|
fn write_body(w: impl Write, v1: Option<&serde_json::Value>, d: &Document, pretty: bool) -> Result<(), FormatError> {
|
|
match v1 {
|
|
Some(v) => write_json(w, v, pretty),
|
|
None => write_json(w, d, pretty),
|
|
}
|
|
}
|
|
|
|
fn write_json(w: impl Write, v: &impl Serialize, pretty: bool) -> Result<(), FormatError> {
|
|
if pretty { serde_json::to_writer_pretty(w, v) } else { serde_json::to_writer(w, v) }.map_err(encode_err)
|
|
}
|
|
|
|
/// The file's top-level object, written member by member (the document is streamed in).
|
|
struct Envelope {
|
|
pretty: bool,
|
|
first: bool,
|
|
}
|
|
|
|
impl Envelope {
|
|
fn field(&mut self, out: &mut impl Write, key: &str, value: &impl Serialize) -> Result<(), FormatError> {
|
|
let json = to_json(value, self.pretty)?;
|
|
self.key(out, key)?;
|
|
Indent { inner: out, on: self.pretty }.write_all(&json).map_err(encode_err)
|
|
}
|
|
|
|
/// The start of member `key` (a plain name): its value follows, indented one level when
|
|
/// pretty (as serde_json would: see [`Indent`]).
|
|
fn key(&mut self, out: &mut impl Write, key: &str) -> Result<(), FormatError> {
|
|
let sep: &[u8] = if std::mem::take(&mut self.first) { b"{" } else { b"," };
|
|
let indent: &[u8] = if self.pretty { b"\n " } else { b"" };
|
|
let colon: &[u8] = if self.pretty { b"\": " } else { b"\":" };
|
|
[sep, indent, b"\"", key.as_bytes(), colon].iter().try_for_each(|part| out.write_all(part)).map_err(encode_err)
|
|
}
|
|
|
|
fn finish(self, out: &mut impl Write) -> Result<(), FormatError> {
|
|
out.write_all(if self.pretty { b"\n}" } else { b"}" }).map_err(encode_err)
|
|
}
|
|
}
|
|
|
|
/// Is this gzip data (a compressed file)?
|
|
pub fn is_compressed(bytes: &[u8]) -> bool {
|
|
bytes.starts_with(&[0x1f, 0x8b])
|
|
}
|
|
|
|
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,
|
|
/// What was done to bring the document within the nesting caps
|
|
/// ([`Document::limit_depth_noting`]: a file saved before them, or a damaged one).
|
|
pub depth_notes: Vec<String>,
|
|
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 {
|
|
/// The file's format version (see [`VERSION`]).
|
|
pub version: u32,
|
|
/// Written under the project's former name (`"format": "drawcraft"`).
|
|
pub legacy: bool,
|
|
}
|
|
|
|
impl FileInfo {
|
|
/// Written by an older version or under the former name: saving rewrites it in today's format.
|
|
pub fn is_old(&self) -> bool {
|
|
self.legacy || self.version < VERSION
|
|
}
|
|
}
|
|
|
|
/// [`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 file = unpack::Unpacked::new(bytes)?;
|
|
// The JSON reader stops at 128 levels (a document's objects use three each).
|
|
let f: File = serde_json::from_slice(file.json()).map_err(|e| {
|
|
let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() };
|
|
FormatError::NotVectorcraft(why)
|
|
})?;
|
|
if f.format != EXTENSION && f.format != LEGACY_EXTENSION {
|
|
return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format)));
|
|
}
|
|
if f.version > VERSION {
|
|
return Err(FormatError::TooNew(f.version));
|
|
}
|
|
// The bytes after the JSON, read in one pass: the images' first, then the profiles'.
|
|
let entries: Vec<[u64; 2]> = f.images.values().chain(f.profiles.values()).filter_map(|i| i.blob).collect();
|
|
let mut tail = file.entries(&entries)?.into_iter();
|
|
let mut bytes_of = |img: &Image| match img.blob {
|
|
Some(_) => tail.next().flatten(),
|
|
None => img.inline(),
|
|
};
|
|
let mut doc = f.document;
|
|
for (k, img) in f.images {
|
|
let bytes = bytes_of(&img).ok_or_else(|| FormatError::BadImage(k.clone()))?;
|
|
let mut blob = ImageBlob::new(img.mime, bytes);
|
|
// Until the linked file is read, the preview stands in for it.
|
|
if img.proxy {
|
|
blob.proxy = Some(blob.bytes.clone());
|
|
}
|
|
doc.images.insert(k, blob);
|
|
}
|
|
// Saved mid-edit by an older version: drop the opacity-mask editing layer.
|
|
doc.drop_edit_modes();
|
|
// Saved before per-fill/stroke overprint: the Overprint Black list becomes overprint flags.
|
|
doc.migrate_overprint_black();
|
|
// Saved before v3: the assigned profiles move out of `unknown`.
|
|
doc.migrate_color_profiles();
|
|
doc.fix_next_id();
|
|
// Untrusted: capped, named and pointing at objects the file has.
|
|
doc.tidy_saved_selections();
|
|
doc.tidy_graph_designs();
|
|
let mut depth_notes = vec![];
|
|
doc.limit_depth_noting(&mut depth_notes);
|
|
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect();
|
|
Ok(NativeFile { doc, depth_notes, 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>,
|
|
}
|
|
|
|
/// The bytes of the member of the file's head `pick` picks (only that entry of the data after
|
|
/// the JSON is kept).
|
|
fn head_member(bytes: &[u8], pick: impl FnOnce(Head) -> Option<Image>) -> Option<Vec<u8>> {
|
|
let file = unpack::Unpacked::new(bytes).ok()?;
|
|
let head: Head = serde_json::from_slice(file.json()).ok()?;
|
|
let img = pick(head)?;
|
|
match img.blob {
|
|
Some(at) => file.entries(&[at]).ok()?.pop().flatten(),
|
|
None => img.inline(),
|
|
}
|
|
}
|
|
|
|
/// 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>> {
|
|
head_member(bytes, |h| h.preview)
|
|
}
|
|
|
|
/// 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>> {
|
|
head_member(bytes, |h| h.pdf)
|
|
}
|
|
|
|
/// Does this look like a `.vectorcraft` file (compressed or not)?
|
|
pub fn sniff(bytes: &[u8]) -> bool {
|
|
const HEAD: usize = 256;
|
|
let head: Cow<'_, [u8]> = if is_compressed(bytes) {
|
|
let mut head = Vec::with_capacity(HEAD);
|
|
// A damaged stream may still unpack its start: judge what came out.
|
|
let _ = flate2::read::GzDecoder::new(bytes).take(HEAD as u64).read_to_end(&mut head);
|
|
Cow::Owned(head)
|
|
} else {
|
|
Cow::Borrowed(bytes.get(..HEAD).unwrap_or(bytes))
|
|
};
|
|
// The cut may split a character.
|
|
let s = String::from_utf8_lossy(&head);
|
|
s.trim_start().starts_with('{') && (s.contains("\"vectorcraft\"") || s.contains("\"drawcraft\""))
|
|
}
|
|
|
|
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
|
|
pub fn base64_encode(data: &[u8]) -> String {
|
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
for c in data.chunks(3) {
|
|
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
|
|
out.push(B64[(n >> 18) as usize & 63] as char);
|
|
out.push(B64[(n >> 12) as usize & 63] as char);
|
|
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
|
|
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
|
let val = |c: u8| B64.iter().position(|&b| b == c).map(|p| p as u32);
|
|
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
|
|
if !clean.len().is_multiple_of(4) {
|
|
return None;
|
|
}
|
|
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
|
|
for c in clean.chunks(4) {
|
|
let mut n = 0u32;
|
|
let mut pad = 0;
|
|
for &b in c {
|
|
n <<= 6;
|
|
if b == b'=' {
|
|
pad += 1;
|
|
} else {
|
|
n |= val(b)?;
|
|
}
|
|
}
|
|
out.push((n >> 16) as u8);
|
|
if pad < 2 {
|
|
out.push((n >> 8) as u8);
|
|
}
|
|
if pad < 1 {
|
|
out.push(n as u8);
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn files_from_before_the_rename_still_open() {
|
|
let d = Document::new(100.0, 50.0);
|
|
let legacy = String::from_utf8(save(&d, false)).unwrap().replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1);
|
|
assert!(legacy.contains("\"drawcraft\""));
|
|
assert!(sniff(legacy.as_bytes()));
|
|
assert_eq!(load(legacy.as_bytes()).unwrap().artboards[0].rect, d.artboards[0].rect);
|
|
assert!(is_native_name("old/Poster.DrawCraft") && is_native_name("new.vectorcraft") && !is_native_name("x.svg"));
|
|
assert!(load(br#"{"format":"other","version":1,"document":{}}"#).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn file_info_tells_old_files() {
|
|
let d = Document::new(10.0, 10.0);
|
|
let current = save(&d, false);
|
|
assert_eq!(load_info(¤t).unwrap().1, FileInfo { version: VERSION, legacy: false });
|
|
assert!(!load_info(¤t).unwrap().1.is_old());
|
|
let text = String::from_utf8(current).unwrap();
|
|
let legacy = text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1);
|
|
assert!(load_info(legacy.as_bytes()).unwrap().1.is_old());
|
|
let v1 = text.replacen(&format!("\"version\":{VERSION}"), "\"version\":1", 1);
|
|
assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false });
|
|
assert!(load_info(v1.as_bytes()).unwrap().1.is_old());
|
|
}
|
|
use vectorcraft_doc::{Appearance, ImageObject, Node, NodeKind};
|
|
use vectorcraft_geom::{Affine, Rect, shapes};
|
|
|
|
#[test]
|
|
fn roundtrip() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let l = d.layers[0].id;
|
|
let id = d.alloc_id();
|
|
d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
|
|
d.images.insert("k".into(), ImageBlob::new("image/png", vec![1, 2, 3, 250]));
|
|
// Only images in use are saved.
|
|
let img = d.alloc_id();
|
|
let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
|
d.insert(Some(l), 1, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap();
|
|
let bytes = save(&d, true);
|
|
assert!(sniff(&bytes));
|
|
let back = load(&bytes).unwrap();
|
|
assert_eq!(back.node_count(), d.node_count());
|
|
assert_eq!(back.images["k"].bytes.as_slice(), &[1, 2, 3, 250]);
|
|
assert_eq!(back.node(id).unwrap().path_data(), d.node(id).unwrap().path_data());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_foreign_and_future() {
|
|
assert!(load(b"{}").is_err());
|
|
let d = Document::new(1.0, 1.0);
|
|
let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap();
|
|
v["version"] = 99.into();
|
|
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(FormatError::TooNew(99))));
|
|
}
|
|
|
|
#[test]
|
|
fn base64() {
|
|
for s in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar"] {
|
|
assert_eq!(base64_decode(&base64_encode(s)).unwrap(), s);
|
|
}
|
|
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
|
}
|
|
}
|