//! The native `.designcraft` format: a zip archive holding //! //! - `mimetype` (stored first, uncompressed): `application/vnd.designcraft+zip` //! - `document.json`: the [`Document`] (pretty JSON, documented by the serde model in `designcraft-doc`) //! - `assets/.`: embedded placed files, byte-for-byte: one per distinct file, and only //! the ones the document references (see [`Document::compact_assets`]) //! - `meta.json`: format version and generator //! //! Older single-file JSON documents (assets as base64 in `assetData`) still open. Documents //! from older format versions are upgraded on load so they lay out as they did. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] use std::collections::{HashMap, HashSet}; use std::io::{Cursor, Read, Write}; use std::sync::Arc; use designcraft_doc::{AssetId, Document, ParaAttrs, Story}; use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; pub const VERSION: u32 = 2; #[derive(Debug, thiserror::Error)] pub enum FormatError { #[error("not a DesignCraft document: {0}")] NotOurs(String), #[error("document is from a newer DesignCraft (format {0}); please update")] TooNew(u32), #[error("{0}")] Io(String), } fn ext_for(mime: &str) -> &'static str { match mime { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp", "image/tiff" => "tif", "application/pdf" => "pdf", "image/svg+xml" => "svg", _ => "bin", } } /// Serialize a document to `.designcraft` bytes. The file holds each distinct placed file once /// and leaves out assets nothing references; `doc` itself keeps them (undo can bring them back). pub fn save(doc: &Document) -> Result, FormatError> { let mut compact = doc.clone(); compact.compact_assets(); let doc = &compact; let io = |e: zip::result::ZipError| FormatError::Io(e.to_string()); let mut buf = Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); z.start_file("mimetype", stored).map_err(io)?; z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("meta.json", deflate).map_err(io)?; let meta = json!({"format": "designcraft", "version": VERSION, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("document.json", deflate).map_err(io)?; let json = serde_json::to_vec_pretty(doc).map_err(|e| FormatError::Io(e.to_string()))?; z.write_all(&json).map_err(|e| FormatError::Io(e.to_string()))?; for (id, a) in &doc.assets { if a.data.is_empty() { continue; } // Already-compressed images are stored as-is. let opts = if matches!(a.mime.as_str(), "image/png" | "image/jpeg" | "image/gif" | "image/webp") { stored } else { deflate }; z.start_file(format!("assets/{}.{}", id.0, ext_for(&a.mime)), opts).map_err(io)?; z.write_all(&a.data).map_err(|e| FormatError::Io(e.to_string()))?; } z.finish().map_err(io)?; } Ok(buf.into_inner()) } /// The file name extension of a DesignCraft document. pub const EXTENSION: &str = "designcraft"; /// Whether `bytes` are a DesignCraft document package: a zip holding `document.json`. Files /// without an extension are recognised by this (the legacy JSON form is too generic to sniff). pub fn is_designcraft(bytes: &[u8]) -> bool { bytes.starts_with(b"PK") && zip::ZipArchive::new(Cursor::new(bytes)).is_ok_and(|z| z.index_for_name("document.json").is_some()) } /// Read `.designcraft` bytes (zip, or the legacy single JSON file). pub fn load(bytes: &[u8]) -> Result { if bytes.starts_with(b"PK") { return load_zip(bytes); } load_legacy_json(bytes) } fn load_zip(bytes: &[u8]) -> Result { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; let read = |z: &mut zip::ZipArchive>, name: &str| -> Option> { let mut f = z.by_name(name).ok()?; let mut v = Vec::new(); f.read_to_end(&mut v).ok()?; Some(v) }; let version = read(&mut z, "meta.json") .and_then(|meta| serde_json::from_slice::(&meta).ok()) .and_then(|m| m.get("version").and_then(Value::as_u64)) .map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX)); if version > VERSION { return Err(FormatError::TooNew(version)); } let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; upgrade_json(&mut v); let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?; let names: Vec = z.file_names().map(str::to_string).collect(); // Files saved with one copy per placement: identical copies share one buffer as they are // read, so memory holds each distinct file once. let mut loaded: HashMap>>> = HashMap::new(); for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; let Some(id) = stem.split('.').next().and_then(|s| s.parse::().ok()) else { continue }; if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) { let same = loaded.entry(data.len()).or_default(); let data = match same.iter().find(|d| ***d == data) { Some(d) => d.clone(), None => { let d = Arc::new(data); same.push(d.clone()); d } }; Arc::make_mut(a).data = data; } } upgrade(&mut doc, version); doc.merge_duplicate_assets(); doc.mark_differing_margins_local(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } /// Bring a document saved in format `from` up to [`VERSION`]. fn upgrade(doc: &mut Document, from: u32) { if from < 2 { // Format 1 laid out an unset Keep Lines Together mode as all lines in paragraph, and drop // caps without Align Left Edge. pin_para_default(doc, |a| &mut a.keep_all_lines, true); pin_para_default(doc, |a| &mut a.drop_cap_align_left, false); } } /// Pin a paragraph attribute to the value an older format resolved it to where nothing set it: /// at the start of each paragraph style chain that leaves it unset, and on paragraphs whose style /// is missing. fn pin_para_default(doc: &mut Document, field: fn(&mut ParaAttrs) -> &mut Option, old: T) { let unset = |a: &ParaAttrs| field(&mut a.clone()).is_none(); let roots: std::collections::HashSet = doc .styles .paragraph .iter() .filter_map(|s| { let chain = doc.styles.para_chain(&s.name); if chain.iter().all(|c| unset(&c.para)) { chain.first().map(|c| c.name.clone()) } else { None } }) .collect(); if !roots.is_empty() { for s in doc.styles_mut().paragraph.iter_mut().filter(|s| roots.contains(&s.name)) { *field(&mut s.para) = Some(old.clone()); } } let styles = doc.styles.clone(); let mut pin = |st: &mut Story| { for p in st.paras.iter_mut().filter(|p| styles.para(&p.style).is_none()) { let v = field(&mut p.para); if v.is_none() { *v = Some(old.clone()); } } }; for st in doc.stories.values_mut() { let st = Arc::make_mut(st); st.for_each_text_mut(&mut pin); for n in &mut st.endnotes { Arc::make_mut(n).text.for_each_text_mut(&mut pin); } } } fn load_legacy_json(bytes: &[u8]) -> Result { let mut v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; upgrade_json(&mut v); let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?; if let Some(data) = v.get("assetData").and_then(Value::as_object) { for (k, s) in data { if let (Ok(id), Some(s)) = (k.parse::(), s.as_str()) && let Some(a) = doc.assets.get_mut(&AssetId(id)) { Arc::make_mut(a).data = Arc::new(base64_decode(s)); } } } upgrade(&mut doc, 1); doc.merge_duplicate_assets(); doc.mark_differing_margins_local(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } /// Bring older documents' JSON up to the current model. Vertical Type was a text frame option /// (`content.options.vertical`); it is the story's direction, which a story takes from its first /// frame. fn upgrade_json(doc: &mut Value) { let mut vertical = HashSet::new(); vertical_frames(doc, &mut vertical, 0); if vertical.is_empty() { return; } let Some(stories) = doc.get_mut("stories").and_then(Value::as_object_mut) else { return }; for st in stories.values_mut() { let first = st.get("frames").and_then(|f| f.get(0)).and_then(Value::as_u64); if let (Some(f), Some(st)) = (first, st.as_object_mut()) && vertical.contains(&f) && !st.contains_key("vertical") { st.insert("vertical".into(), Value::Bool(true)); } } } /// Ids of the text frames in `v` (any depth: groups, anchored objects) whose options say vertical. fn vertical_frames(v: &Value, out: &mut HashSet, depth: usize) { // serde_json already refuses JSON nested deeper than 128 levels. if depth > 256 { return; } match v { Value::Object(m) => { if let (Some(id), Some(c)) = (m.get("id").and_then(Value::as_u64), m.get("content")) && c.get("type").and_then(Value::as_str) == Some("text") && c.get("options").and_then(|o| o.get("vertical")).and_then(Value::as_bool) == Some(true) { out.insert(id); } for c in m.values() { vertical_frames(c, out, depth + 1); } } Value::Array(a) => { for c in a { vertical_frames(c, out, depth + 1); } } _ => {} } } fn base64_decode(s: &str) -> Vec { let mut out = Vec::with_capacity(s.len() * 3 / 4); let (mut buf, mut bits) = (0u32, 0); for b in s.bytes() { let v = match b { b'A'..=b'Z' => b - b'A', b'a'..=b'z' => b - b'a' + 26, b'0'..=b'9' => b - b'0' + 52, b'+' | b'-' => 62, b'/' | b'_' => 63, _ => continue, } as u32; buf = buf << 6 | v; bits += 6; if bits >= 8 { bits -= 8; out.push((buf >> bits) as u8); } } out } #[cfg(test)] mod tests { use super::*; use designcraft_doc::build::NewDocument; use designcraft_doc::geom::Rect; use designcraft_doc::{Asset, ParaFormat, SpreadRef}; fn png_asset(d: &mut Document, data: &[u8]) -> AssetId { let aid = AssetId(d.alloc()); let a = Asset { id: aid, name: "x.png".into(), mime: "image/png".into(), data: Arc::new(data.to_vec()), pixels: Some((1, 1)), ..Default::default() }; d.assets.insert(aid, Arc::new(a)); aid } fn place(d: &mut Document, asset: AssetId, crop: f64) -> designcraft_doc::ItemId { use designcraft_doc::geom::{Affine, shapes}; use designcraft_doc::{Content, Graphic, Item, ItemId, Shape}; let id = ItemId(d.alloc()); let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0))); it.content = Content::Graphic(Graphic { asset, size: (1.0, 1.0), xf: Affine::translate((crop, crop)), clip: None, auto_fit: Default::default(), fit_align: 4, crop: [crop; 4], wrap: Default::default(), }); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); id } fn asset_entries(bytes: &[u8]) -> usize { let z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); z.file_names().filter(|n| n.starts_with("assets/")).count() } /// Zip `d` the way files were saved with one asset per placement (nothing merged or pruned). fn save_uncompacted(d: &Document) -> Vec { let mut buf = Cursor::new(Vec::new()); let mut z = zip::ZipWriter::new(&mut buf); let opts = zip::write::SimpleFileOptions::default(); z.start_file("mimetype", opts).unwrap(); z.write_all(MIME.as_bytes()).unwrap(); z.start_file("document.json", opts).unwrap(); z.write_all(&serde_json::to_vec(d).unwrap()).unwrap(); for (id, a) in &d.assets { z.start_file(format!("assets/{}.png", id.0), opts).unwrap(); z.write_all(&a.data).unwrap(); } z.finish().unwrap(); buf.into_inner() } #[test] fn roundtrip_with_assets() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); let lid = d.default_layer(); d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "héllo\nworld", ParaFormat::default()).unwrap(); let aid = png_asset(&mut d, &[1, 2, 3, 4]); place(&mut d, aid, 0.0); let bytes = save(&d).unwrap(); assert!(bytes.starts_with(b"PK")); let back = load(&bytes).unwrap(); assert_eq!(back.page_count(), 3); assert_eq!(*back.assets[&aid].data, vec![1, 2, 3, 4]); assert_eq!(back.stories, d.stories); } /// `doc` as format 1 wrote it. fn save_v1(doc: &Document) -> Vec { let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); z.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap(); z.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap(); z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); z.write_all(&serde_json::to_vec(doc).unwrap()).unwrap(); z.finish().unwrap().into_inner() } fn para_style(name: &str, based_on: Option<&str>, keep_all_lines: Option) -> designcraft_doc::ParagraphStyle { designcraft_doc::ParagraphStyle { name: name.into(), based_on: based_on.map(str::to_string), next_style: None, para: designcraft_doc::ParaAttrs { keep_lines_together: Some(true), keep_all_lines, ..Default::default() }, chars: Default::default(), shortcut: String::new(), } } /// Format 1 resolved an unset Keep Lines Together mode as all lines: those documents still /// do after loading, wherever the mode comes from. New documents keep lines at start/end. #[test] fn format_1_keep_lines_without_mode_stays_all_lines() { let mut d = Document::new(&NewDocument::default()); let st = d.styles_mut(); st.paragraph.push(para_style("Root", None, None)); st.paragraph.push(para_style("Child", Some("Root"), None)); st.paragraph.push(para_style("Explicit", None, Some(false))); st.paragraph.push(para_style("Under explicit", Some("Explicit"), None)); st.paragraph.push(para_style("Loop A", Some("Loop B"), None)); st.paragraph.push(para_style("Loop B", Some("Loop A"), None)); let lid = d.default_layer(); let names = [designcraft_doc::story::BASIC_PARAGRAPH, "Root", "Child", "Explicit", "Under explicit", "Loop A", "Loop B", "Missing"]; let text = names.join("\n"); let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, &text, ParaFormat::default()).unwrap(); { let s = d.story_mut(sid).unwrap(); for (p, name) in s.paras.iter_mut().zip(names) { p.style = name.into(); p.para.keep_lines_together = Some(true); } s.insert_note(1, "Note", ParaFormat { style: "Missing".into(), ..Default::default() }); } let back = load(&save_v1(&d)).unwrap(); let resolve = |f: &ParaFormat| back.styles.resolve_para(f).0; let story = back.story(sid).unwrap(); for (p, name) in story.paras.iter().zip(names) { let pp = resolve(p); assert!(pp.keep_lines_together); assert_eq!(pp.keep_all_lines, !name.contains("xplicit"), "{name}"); } assert!(resolve(&story.notes[0].text.paras[0]).keep_all_lines, "footnote with a missing style"); assert!(back.styles.resolve_para_style("Loop A").0.keep_all_lines && back.styles.resolve_para_style("Loop B").0.keep_all_lines); // Pinned where the chains start; explicit and inheriting styles are left as they were. assert_eq!(back.styles.para("Root").unwrap().para.keep_all_lines, Some(true)); assert_eq!(back.styles.para("Child").unwrap().para.keep_all_lines, None); assert_eq!(back.styles.para("Under explicit").unwrap().para.keep_all_lines, None); // The current format keeps the new default: at start/end of paragraph. let now = load(&save(&d).unwrap()).unwrap(); let p = &now.story(sid).unwrap().paras[0]; assert!(!now.styles.resolve_para(p).0.keep_all_lines); assert!(!designcraft_doc::ParaProps::default().keep_all_lines); } /// Format 1 wrote paragraph rules, text frame options and numbered lists in full, so their /// old defaults (black rules, auto-size from the top centre, lists continuing across stories) /// load as saved. New documents take InDesign's. #[test] fn format_1_rules_frames_and_lists_load_as_saved() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let old_rule = designcraft_doc::Rule { on: true, color: designcraft_doc::color::swatch::BLACK.into(), ..Default::default() }; let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "Ruled", ParaFormat::default()).unwrap(); d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(old_rule.clone()); d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.auto_size_ref = 1; d.settings.lists.push(designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: true }); let back = load(&save_v1(&d)).unwrap(); assert_eq!(back.story(sid).unwrap().paras[0].para.rule_above, Some(old_rule)); assert_eq!(back.item(fid).unwrap().text_frame().unwrap().options.auto_size_ref, 1); assert!(back.settings.lists[0].continue_across_stories); assert_eq!(designcraft_doc::Rule::default().color, designcraft_doc::TEXT_COLOR); assert_eq!(designcraft_doc::TextFrameOptions::default().auto_size_ref, 4); assert!(!designcraft_doc::NumberedList::default().continue_across_stories); } /// Format 1 set drop caps without Align Left Edge: those documents still do where nothing set /// it. New documents align the drop cap's left edge. #[test] fn format_1_drop_caps_stay_unaligned() { let mut d = Document::new(&NewDocument::default()); let st = d.styles_mut(); st.paragraph.push(para_style("Opener", None, None)); st.paragraph.push(designcraft_doc::ParagraphStyle { para: designcraft_doc::ParaAttrs { drop_cap_align_left: Some(true), ..Default::default() }, ..para_style("Aligned", None, None) }); let lid = d.default_layer(); let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "One\nTwo\nThree", ParaFormat::default()).unwrap(); { let s = d.story_mut(sid).unwrap(); for (p, name) in s.paras.iter_mut().zip(["Opener", "Aligned", "Missing"]) { p.style = name.into(); p.para.drop_cap_lines = Some(2); p.para.drop_cap_chars = Some(1); } } let back = load(&save_v1(&d)).unwrap(); let aligned: Vec = back.story(sid).unwrap().paras.iter().map(|p| back.styles.resolve_para(p).0.drop_cap_align_left).collect(); assert_eq!(aligned, [false, true, false]); let now = load(&save(&d).unwrap()).unwrap(); assert!(now.styles.resolve_para(&now.story(sid).unwrap().paras[0]).0.drop_cap_align_left); } #[test] fn saves_hold_each_used_file_once() { let mut d = Document::new(&NewDocument::default()); let first = png_asset(&mut d, &[7; 64]); let copy = png_asset(&mut d, &[7; 64]); let replaced = png_asset(&mut d, &[9; 64]); let a = place(&mut d, first, 1.0); let b = place(&mut d, copy, 2.0); assert_eq!(d.assets.len(), 3); // Duplicates merge into the first asset, the unreferenced one is left out, and every // placement keeps its own crop and transform. let bytes = save(&d).unwrap(); assert_eq!(asset_entries(&bytes), 1); assert_eq!(d.assets.len(), 3, "the open document keeps its assets"); let back = load(&bytes).unwrap(); assert_eq!(back.assets.keys().copied().collect::>(), vec![first]); assert!(!back.assets.contains_key(&replaced)); for (id, crop) in [(a, 1.0), (b, 2.0)] { let g = back.item(id).and_then(|i| i.graphic()).unwrap(); assert_eq!((g.asset, g.crop, g.xf), (first, [crop; 4], d.item(id).unwrap().graphic().unwrap().xf)); } } #[test] fn files_with_identical_bytes_at_different_paths_keep_their_paths() { let mut d = Document::new(&NewDocument::default()); let ids: Vec = (0..3).map(|_| png_asset(&mut d, &[3; 32])).collect(); for (id, link) in ids.iter().zip(["/p/a.png", "/q/b.png"]) { if let Some(a) = d.assets.get_mut(id) { Arc::make_mut(a).link = Some(link.into()); } } let items: Vec<_> = ids.iter().enumerate().map(|(k, id)| place(&mut d, *id, k as f64)).collect(); for bytes in [save(&d).unwrap(), save_uncompacted(&d)] { let back = load(&bytes).unwrap(); assert_eq!(back.assets.len(), 3); let links: Vec> = items .iter() .map(|i| back.item(*i).and_then(|i| i.graphic()).and_then(|g| back.assets.get(&g.asset)).unwrap().link.as_deref()) .collect(); assert_eq!(links, [Some("/p/a.png"), Some("/q/b.png"), None]); } } #[test] fn older_files_with_one_asset_per_placement_open_with_one() { let mut d = Document::new(&NewDocument::default()); let ids: Vec = (0..3).map(|_| png_asset(&mut d, &[5; 32])).collect(); for (k, id) in ids.iter().enumerate() { place(&mut d, *id, k as f64); } let back = load(&save_uncompacted(&d)).unwrap(); assert_eq!(back.assets.len(), 1); assert_eq!(asset_entries(&save(&back).unwrap()), 1); } /// The object of the item `id` in a document's JSON. fn item_json(v: &mut Value, id: u64) -> Option<&mut Value> { if v.get("id").and_then(Value::as_u64) == Some(id) && v.get("content").is_some() { return Some(v); } match v { Value::Object(m) => m.values_mut().find_map(|c| item_json(c, id)), Value::Array(a) => a.iter_mut().find_map(|c| item_json(c, id)), _ => None, } } #[test] fn frame_level_vertical_text_opens_as_a_vertical_story() { // Documents that set Vertical on the frame: the story takes its first frame's direction. let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap(); let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 72.0, 428.0, 400.0), lid, "", ParaFormat::default()).unwrap(); d.thread(a, b).unwrap(); let (c, other) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(450.0, 72.0, 550.0, 400.0), lid, "横", ParaFormat::default()).unwrap(); let mut v = serde_json::to_value(&d).unwrap(); item_json(&mut v, a.0).unwrap()["content"]["options"]["vertical"] = json!(true); item_json(&mut v, c.0).unwrap()["content"]["options"]["vertical"] = json!(false); let json = serde_json::to_vec(&v).unwrap(); let mut zipped = Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut zipped); z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); z.write_all(&json).unwrap(); z.finish().unwrap(); } for bytes in [json.clone(), zipped.into_inner()] { let back = load(&bytes).unwrap(); assert!(back.stories[&sid].vertical); assert!(!back.stories[&other].vertical); // Saved again, the direction is the story's. let again = load(&save(&back).unwrap()).unwrap(); assert!(again.stories[&sid].vertical && !again.stories[&other].vertical); } } #[test] fn documents_saved_before_glyph_fallback_keep_their_fallback_fonts() { let d = Document::new(&NewDocument::default()); assert!(!d.settings.glyph_fallback); assert!(!load(&save(&d).unwrap()).unwrap().settings.glyph_fallback, "saved and read as off"); let mut on = d.clone(); on.settings.glyph_fallback = true; assert!(load(&save(&on).unwrap()).unwrap().settings.glyph_fallback); // Written before the setting existed: drawn from fallback fonts, as they were. let mut v = serde_json::to_value(&d).unwrap(); v["settings"].as_object_mut().unwrap().remove("glyphFallback").unwrap(); assert!(load(&serde_json::to_vec(&v).unwrap()).unwrap().settings.glyph_fallback); } /// A saved tab list over the limits loads repaired, as an imported one does: a non-finite /// position (saved as `null`) dropped, positions clamped, leaders cut, the count capped. #[test] fn saved_tab_lists_load_sanitized() { use designcraft_doc::{MAX_TAB_LEADER, MAX_TAB_POSITION, MAX_TAB_STOPS, TabAlign, TabStop}; let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 300.0, 300.0), lid, "a\tb", ParaFormat::default()).unwrap(); let stop = |position: f64| TabStop { position, align: TabAlign::Right, leader: "0123456789".into(), align_on: String::new() }; let mut tabs: Vec = (0..500).rev().map(|i| stop(i as f64 * 40.0)).collect(); tabs.push(stop(f64::NAN)); d.story_mut(sid).unwrap().paras[0].para.tabs = Some(tabs); let bytes = save(&d).unwrap(); let back = load(&bytes).unwrap(); let tabs = back.stories[&sid].paras[0].para.tabs.clone().unwrap(); assert_eq!(tabs.len(), MAX_TAB_STOPS); assert!(tabs.windows(2).all(|w| w[0].position < w[1].position), "sorted"); assert!(tabs.iter().all(|t| t.position.is_finite() && t.position <= MAX_TAB_POSITION && t.leader.chars().count() <= MAX_TAB_LEADER)); assert_eq!(tabs.last().map(|t| t.position), Some(3960.0), "the first stops by position"); } #[test] fn rejects_garbage_and_newer_versions() { assert!(load(b"nope").is_err()); let mut buf = Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); z.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap(); z.write_all(br#"{"version": 99}"#).unwrap(); z.finish().unwrap(); } assert!(matches!(load(&buf.into_inner()), Err(FormatError::TooNew(99)))); } fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { use designcraft_doc::{CharAttrs, ParaAttrs}; let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); let base = d.styles.default_paragraph.clone(); let chars = CharAttrs { leading_aki: Some(Some(4.0)), trailing_aki: Some(Some(3.0)), underline_weight: Some(Some(2.0)), underline_offset: Some(Some(1.0)), strikethrough_weight: Some(Some(2.0)), strikethrough_offset: Some(Some(1.0)), ..Default::default() }; // Legacy JSON loads as format 1, whose upgrade pins Keep Lines Together and Align Left // Edge where a style chain leaves them unset; the base style sets them so both // formats resolve the same. let para = ParaAttrs { start_at: Some(Some(7)), paragraph_kashida_width: Some(Some(0.5)), kinsoku: Some(Some(Default::default())), keep_all_lines: Some(true), drop_cap_align_left: Some(true), ..Default::default() }; let style = std::sync::Arc::make_mut(&mut d.styles).paragraph.iter_mut().find(|s| s.name == base).unwrap(); style.chars = chars; style.para = para; let clear_chars = CharAttrs { leading_aki: Some(None), trailing_aki: Some(None), underline_weight: Some(None), underline_offset: Some(None), strikethrough_weight: Some(None), strikethrough_offset: Some(None), ..Default::default() }; let clear_para = ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), ..Default::default() }; let selected = if style_clear { std::sync::Arc::make_mut(&mut d.styles).paragraph.push(designcraft_doc::styles::ParagraphStyle { name: "Defaults".into(), based_on: Some(base.clone()), next_style: None, para: clear_para.clone(), chars: clear_chars.clone(), shortcut: String::new(), }); "Defaults".to_string() } else { base }; let format = ParaFormat { style: selected, para: if local_clear { clear_para } else { ParaAttrs::default() }, chars: if local_clear { clear_chars } else { CharAttrs::default() }, table: None, }; let layer = d.default_layer(); d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), layer, "clear", format).unwrap(); d } fn r7a_nullable_resolution(d: &Document) -> (designcraft_doc::ParaProps, designcraft_doc::CharProps) { let s = d.stories.values().find(|s| s.text == "clear").unwrap(); d.styles.resolve_para(s.paras.first().unwrap()) } fn r7a_assert_nullable_defaults_survive(d: &Document) { let expected = r7a_nullable_resolution(d); assert_eq!(expected.0.start_at, None); assert_eq!(expected.0.paragraph_kashida_width, None); assert_eq!(expected.0.kinsoku, None); assert_eq!(expected.1.leading_aki, None); // Legacy JSON is format 1: its upgrade pins older defaults on the other styles, so only // the native ZIP compares as the whole document. for (bytes, native) in [(save(d).unwrap(), true), (serde_json::to_vec(d).unwrap(), false)] { let back = load(&bytes).unwrap(); assert_eq!(r7a_nullable_resolution(&back), expected, "saved explicit defaults must not become inherited values"); if native { assert_eq!(back, *d, "the native ZIP preserves the complete document"); } } } #[test] fn r7a_native_local_default_text_overrides_survive_zip_and_legacy_json() { r7a_assert_nullable_defaults_survive(&r7a_nullable_document(true, false)); } #[test] fn r7a_native_named_style_default_overrides_survive_zip_and_legacy_json() { r7a_assert_nullable_defaults_survive(&r7a_nullable_document(false, true)); } #[test] fn r7a_native_nullable_missing_and_explicit_value_controls() { let d = r7a_nullable_document(false, false); assert_eq!(r7a_nullable_resolution(&d).0.start_at, Some(7)); assert_eq!(r7a_nullable_resolution(&d).1.leading_aki, Some(4.0)); for (bytes, native) in [(save(&d).unwrap(), true), (serde_json::to_vec(&d).unwrap(), false)] { let back = load(&bytes).unwrap(); assert_eq!(r7a_nullable_resolution(&back), r7a_nullable_resolution(&d)); if native { assert_eq!(back, d); } } let attrs: designcraft_doc::CharAttrs = serde_json::from_value(json!({"size":null})).unwrap(); assert!(attrs.size.is_none(), "nonnullable fields retain their existing null contract"); let props: designcraft_doc::CharProps = serde_json::from_value(json!({"leadingAki":null})).unwrap(); assert_eq!(props.leading_aki, None); } }