mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
A native save was pretty-printed when the document had at most 2,000 layers and objects and its compact JSON was at most 1 MiB. Pretty printing puts every coordinate on its own indented line, so traced art with few paths and many anchors could pass that check and be written at about five times the size of its compact JSON. The 1 MiB limit now applies to the pretty-printed document, indented as the file holds it and in the form the save writes: version 1 anchors, and placed documents expanded for older versions or Include Linked Files. Documents whose pretty-printed JSON is over 1 MiB are saved as compact JSON, even when their compact JSON is under it. The 2,000-object rule and compressed saves are unchanged. docs/mcp.md describes the rule.
786 lines
35 KiB
Rust
786 lines
35 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 std::sync::Arc;
|
|
|
|
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: 1 GiB in the web
|
|
/// build, whose 4 GiB address space also holds the JSON, the document and the app, so 4 GiB of
|
|
/// entries failed to allocate rather than being refused.
|
|
pub const MAX_BLOBS: u64 = if cfg!(target_arch = "wasm32") { 1 << 30 } else { 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 native file without the data after it (version 4 keeps images there): a
|
|
/// complete JSON value whose `images` give each image's MIME type and where its data was, without
|
|
/// the data.
|
|
pub fn json_part(file: &[u8]) -> &[u8] {
|
|
split_tail(file).0
|
|
}
|
|
|
|
/// 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 [`Self::pretty_fits`]).
|
|
pub fn for_doc(doc: &Document) -> Self {
|
|
let o = Self::default();
|
|
Self { pretty: o.pretty_fits(doc), ..o }
|
|
}
|
|
|
|
/// Is `doc` small enough to save pretty-printed with these options: at most
|
|
/// [`PRETTY_MAX_OBJECTS`] objects, and at most [`PRETTY_MAX_BYTES`] of pretty-printed JSON
|
|
/// as [`save_to`] writes the document with them (in their version)?
|
|
pub fn pretty_fits(&self, doc: &Document) -> bool {
|
|
let objects: usize = doc.layers.iter().map(|l| l.count()).sum();
|
|
objects <= PRETTY_MAX_OBJECTS && body(doc, self).is_ok_and(|b| pretty_size_at_most(&b, PRETTY_MAX_BYTES))
|
|
}
|
|
}
|
|
|
|
/// Is the document in `b` at most `limit` bytes as pretty-printed JSON, indented as a pretty file
|
|
/// holds it? Serializing stops as soon as it is past the limit, so a large document costs no more
|
|
/// than `limit` bytes of work.
|
|
fn pretty_size_at_most(b: &Body, 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(())
|
|
}
|
|
}
|
|
// The document is a member of the file's top-level object, indented one level more.
|
|
write_body(Indent { inner: Capped { left: limit }, on: true }, b.v1.as_ref(), &b.d, true).is_ok()
|
|
}
|
|
|
|
/// The document as [`save_to`] writes it, with the images it lists outside the document.
|
|
struct Body {
|
|
d: Document,
|
|
/// The version 1 form of `d`, written instead of it.
|
|
v1: Option<serde_json::Value>,
|
|
blobs: BTreeMap<String, ImageBlob>,
|
|
/// Images only linked images show (see [`Document::linked_only_images`]).
|
|
linked: std::collections::BTreeSet<String>,
|
|
}
|
|
|
|
/// `doc` as [`save_to`] writes it with `o`: without editing-mode working copies, with placed
|
|
/// documents as their art for older versions, and its images taken out.
|
|
fn body(doc: &Document, o: &SaveOptions) -> Result<Body, FormatError> {
|
|
let version = o.version;
|
|
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 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 version < 3 {
|
|
d.store_legacy_color_profiles();
|
|
}
|
|
let v1 = if version == 1 {
|
|
let mut v = serde_json::to_value(&d).map_err(encode_err)?;
|
|
legacy::anchors_to_v1(&mut v);
|
|
Some(v)
|
|
} else {
|
|
None
|
|
};
|
|
Ok(Body { d, v1, blobs, linked })
|
|
}
|
|
|
|
/// 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 pretty-printed 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) grows about fivefold.
|
|
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 Body { d, v1, blobs, linked } = body(doc, o)?;
|
|
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), and the paths
|
|
// read at `MAX_READ_ANCHORS` anchors together.
|
|
let read = || serde_json::from_slice::<File>(file.json());
|
|
let f: File = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).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 tail = file.entries(&entries)?;
|
|
// Entries naming the same bytes share them; a profile's own copy of shared bytes counts
|
|
// against the data's size, so copies never add up to more than the file has (#3574).
|
|
let mut distinct = HashSet::new();
|
|
let mut spare: u64 = tail.iter().flatten().filter(|b| distinct.insert(Arc::as_ptr(b))).map(|b| b.len() as u64).sum();
|
|
let mut tail = tail.into_iter();
|
|
let mut bytes_of = |img: &Image| match img.blob {
|
|
Some(_) => tail.next().flatten(),
|
|
None => img.inline().map(Arc::new),
|
|
};
|
|
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 { mime: img.mime, bytes, proxy: None };
|
|
// 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: the bleed and type proportions, within the ranges Document Setup takes.
|
|
doc.setup.keep_in_range();
|
|
// Untrusted: capped, named and pointing at objects the file has.
|
|
doc.tidy_saved_selections();
|
|
doc.tidy_graph_designs();
|
|
doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND);
|
|
let mut depth_notes = vec![];
|
|
doc.limit_depth_noting(&mut depth_notes);
|
|
// Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph
|
|
// spacing, and its tab stops, each within its range (after the depth limit: the walk recurses).
|
|
doc.keep_type_values_in_range();
|
|
// Untrusted: ruby ranges over the type's text, clamped to it (after the depth limit too).
|
|
doc.fix_ruby();
|
|
// Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
|
|
doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
|
|
// Untrusted: the superscript, subscript and small caps proportions of the type, the graph
|
|
// labels and the character styles, within the ranges `document.setup` takes (after the depth
|
|
// limit: the walk recurses).
|
|
doc.keep_scripts_in_range();
|
|
let profiles = f
|
|
.profiles
|
|
.into_iter()
|
|
.filter_map(|(name, icc)| Some((name, bytes_of(&icc)?)))
|
|
.map(|(name, icc)| match Arc::try_unwrap(icc) {
|
|
Ok(icc) => Ok((name, icc)),
|
|
Err(icc) => {
|
|
spare = spare.checked_sub(icc.len() as u64).ok_or(FormatError::TooBig)?;
|
|
Ok((name, icc.to_vec()))
|
|
}
|
|
})
|
|
.collect::<Result<_, FormatError>>()?;
|
|
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().map(Arc::unwrap_or_clone),
|
|
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(), pixels: None };
|
|
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");
|
|
}
|
|
}
|