mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M0.7/M0.8: egui UI (Illustrator layout), control channel, desktop app
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
bfee80a22c
commit
bea0c16636
193 files changed
+11666
-61
No files matched your search
@@ -3,7 +3,7 @@ name = "drawcraft-format"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DrawCraft native .drawcraft format"
|
||||
description = "DrawCraft native .drawcraft format (versioned JSON, embedded images)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -13,3 +13,6 @@ drawcraft-doc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
drawcraft-geom = { workspace = true }
|
||||
+158
-1
@@ -1,2 +1,159 @@
|
||||
//! DrawCraft native .drawcraft format
|
||||
//! The native `.drawcraft` format.
|
||||
//!
|
||||
//! A `.drawcraft` file is UTF-8 JSON:
|
||||
//! ```json
|
||||
//! { "format": "drawcraft", "version": 1, "generator": "DrawCraft 0.1.0",
|
||||
//! "document": { …drawcraft_doc::Document… },
|
||||
//! "images": { "<key>": { "mime": "image/png", "data": "<base64>" } } }
|
||||
//! ```
|
||||
//! It is lossless for everything in the document model and preserves unknown fields under
|
||||
//! `document.unknown`. Readers must reject files whose `version` is newer than they support.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_doc::{Document, ImageBlob};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const VERSION: u32 = 1;
|
||||
pub const EXTENSION: &str = "drawcraft";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FormatError {
|
||||
#[error("not a DrawCraft file: {0}")]
|
||||
NotDrawcraft(String),
|
||||
#[error("file version {0} is newer than this DrawCraft supports ({VERSION})")]
|
||||
TooNew(u32),
|
||||
#[error("invalid image data for `{0}`")]
|
||||
BadImage(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Image {
|
||||
mime: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct File {
|
||||
format: String,
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
generator: String,
|
||||
document: Document,
|
||||
#[serde(default)]
|
||||
images: BTreeMap<String, Image>,
|
||||
}
|
||||
|
||||
/// Serialize a document (pretty = human-diffable).
|
||||
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
let images = doc.images.iter().map(|(k, b)| (k.clone(), Image { mime: b.mime.clone(), data: base64_encode(&b.bytes) })).collect();
|
||||
let f = File { format: "drawcraft".into(), version: VERSION, generator: format!("DrawCraft {}", env!("CARGO_PKG_VERSION")), document: doc.clone(), images };
|
||||
if pretty { serde_json::to_vec_pretty(&f).unwrap_or_default() } else { serde_json::to_vec(&f).unwrap_or_default() }
|
||||
}
|
||||
|
||||
pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
let f: File = serde_json::from_slice(bytes).map_err(|e| FormatError::NotDrawcraft(e.to_string()))?;
|
||||
if f.format != "drawcraft" {
|
||||
return Err(FormatError::NotDrawcraft(format!("format is `{}`", f.format)));
|
||||
}
|
||||
if f.version > VERSION {
|
||||
return Err(FormatError::TooNew(f.version));
|
||||
}
|
||||
let mut doc = f.document;
|
||||
for (k, img) in f.images {
|
||||
let bytes = base64_decode(&img.data).ok_or_else(|| FormatError::BadImage(k.clone()))?;
|
||||
doc.images.insert(k, ImageBlob { mime: img.mime, bytes: Arc::new(bytes) });
|
||||
}
|
||||
doc.fix_next_id();
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Does this look like a `.drawcraft` file?
|
||||
pub fn sniff(bytes: &[u8]) -> bool {
|
||||
let head = &bytes[..bytes.len().min(256)];
|
||||
std::str::from_utf8(head).is_ok_and(|s| s.trim_start().starts_with('{') && 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::*;
|
||||
use drawcraft_doc::{Appearance, Node};
|
||||
use drawcraft_geom::{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 { mime: "image/png".into(), bytes: Arc::new(vec![1, 2, 3, 250]) });
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user