diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 56d5c0cd8..0c80ffedf 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -874,7 +874,8 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { let r = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.inner_bounds(); let k = (r.width() / nw).max(r.height() / nh); let xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k); - (r, xf, designcraft_doc::Fitting::FillProportionally) + // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. + (r, xf, Default::default()) } else { if d.spread(sr).is_none() { return Err(bad("place.drop", "no such spread")); @@ -1460,222 +1461,7 @@ mod new_document_tests { } #[cfg(test)] -pub(crate) mod eps_place_tests { - use super::*; - - pub(crate) fn tiff_eps() -> Vec { - let mut tiff = b"II".to_vec(); - tiff.extend(42u16.to_le_bytes()); - tiff.extend(8u32.to_le_bytes()); - tiff.extend(10u16.to_le_bytes()); - for (tag, ty, count, value) in [ - (256u16, 4u16, 1u32, 2u32), - (257, 4, 1, 2), - (258, 3, 3, 134), - (259, 3, 1, 1), - (262, 3, 1, 2), - (273, 4, 1, 140), - (277, 3, 1, 3), - (278, 4, 1, 2), - (279, 4, 1, 12), - (284, 3, 1, 1), - ] { - tiff.extend(tag.to_le_bytes()); - tiff.extend(ty.to_le_bytes()); - tiff.extend(count.to_le_bytes()); - tiff.extend(value.to_le_bytes()); - } - tiff.extend(0u32.to_le_bytes()); - for _ in 0..3 { - tiff.extend(8u16.to_le_bytes()); - } - tiff.extend([255, 0, 0].repeat(4)); - let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EOF\n"; - let mut eps = vec![0xC5, 0xD0, 0xD3, 0xC6]; - for n in [30usize, ps.len(), 0, 0, 30 + ps.len(), tiff.len()] { - eps.extend((n as u32).to_le_bytes()); - } - eps.extend([255, 255]); - eps.extend(ps); - eps.extend(tiff); - eps - } - - #[test] - fn eps_degenerate_bounds_are_rejected_without_edits() { - for bounds in ["0 0 1e-320 1e-320", "0 0 1e308 1e308", "0 0 inf 10"] { - let source = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: {bounds}\n%%EOF\n"); - for command in ["file.place", "place.load"] { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let revision = s.doc().unwrap().revision; - assert!( - s.execute(command, &json!({"base64": base64_encode(source.as_bytes()), "name": "bad.eps", "width": 100})).is_err(), - "{command}: {bounds}" - ); - assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - assert_eq!(s.doc().unwrap().revision, revision); - assert!(s.loaded.is_none()); - designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); - } - } - } - - #[test] - fn eps_fitting_overflow_is_rejected_before_document_edits() { - let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 0.01 0.01\n%%EOF\n"; - let params = json!({"base64": base64_encode(source), "name": "small.eps"}); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - assert!(s.execute("file.place", &json!({"base64": base64_encode(source), "name": "small.eps", "width": 1e308})).is_err()); - assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - s.execute("place.load", ¶ms).unwrap(); - let before = s.doc().unwrap().doc.clone(); - assert!(s.execute("place.drop", &json!({"rect": [0, 0, 1e308, 1e308]})).is_err()); - assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - s.execute("place.drop", &json!({"rect": [72, 72, 172, 122]})).unwrap(); - designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); - } - - #[test] - fn eps_table_and_data_merge_reject_fitting_overflow() { - let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 0.01 0.01\n%%EOF\n"; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect":[72,72,372,272],"content":"text"})).unwrap(); - s.execute("text.select", &json!({"story":r["story"],"anchor":0,"focus":0})).unwrap(); - s.execute("table.insert", &json!({"rows":1,"cols":1,"width":1e308})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - assert!(s.execute("table.placeGraphic", &json!({"base64":base64_encode(source),"name":"small.eps","fit":"fill"})).is_err()); - assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - designcraft_format::load(&designcraft_format::save(&before).unwrap()).unwrap(); - - let path = std::env::temp_dir().join(format!("dc-eps-fit-{}.eps", std::process::id())); - std::fs::write(&path, source).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("data.source.select", &json!({"bytes":base64_encode(format!("@Photo\n{}\n",path.display()).as_bytes()),"name":"source.csv"})) - .unwrap(); - let r = s.execute("frame.create", &json!({"rect":[0,0,1e308,100],"content":"graphic"})).unwrap(); - s.execute("data.placeholder.add", &json!({"field":"Photo","item":r["id"]})).unwrap(); - let result = s.execute("data.merge", &json!({"fitting":"fillProportionally"})).unwrap(); - assert_eq!(result["missingImages"].as_array().unwrap().len(), 1); - assert!(s.doc().unwrap().doc.assets.is_empty()); - designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); - let _ = std::fs::remove_file(path); - } - - #[test] - fn eps_table_and_data_merge_use_bounding_box_points() { - let dir = std::env::temp_dir().join(format!("dc-eps-routes-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - for (index, bytes) in [b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EOF\n".to_vec(), tiff_eps()].into_iter().enumerate() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72,72,372,272], "content":"text"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor":0, "focus":0})).unwrap(); - s.execute("table.insert", &json!({"rows":1,"cols":1})).unwrap(); - s.execute("table.placeGraphic", &json!({"base64": base64_encode(&bytes), "name":"logo.eps"})).unwrap(); - let table = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); - assert_eq!(table.cells[0].graphic.as_ref().unwrap().size, (100.0, 50.0), "table {index}"); - let path = dir.join(format!("logo-{index}.eps")); - std::fs::write(&path, &bytes).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute( - "data.source.select", - &json!({"bytes": base64_encode(format!("@Photo\n{}\n", path.display()).as_bytes()), "name":"source.csv"}), - ) - .unwrap(); - let r = s.execute("frame.create", &json!({"rect":[72,72,372,272],"content":"graphic"})).unwrap(); - s.execute("data.placeholder.add", &json!({"field":"Photo","item":r["id"]})).unwrap(); - let result = s.execute("data.merge", &json!({"fitting":"none"})).unwrap(); - assert!(result["missingImages"].as_array().unwrap().is_empty()); - let g = s - .doc() - .unwrap() - .doc - .spreads - .iter() - .flat_map(|sp| &sp.items) - .find_map(|it| if let Content::Graphic(g) = &it.content { Some(g) } else { None }) - .unwrap(); - assert_eq!(g.size, (100.0, 50.0), "merge {index}"); - designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); - } - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn eps_idml_roundtrip_preserves_source_and_resource_type() { - use std::io::Read; - for bytes in [b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EOF\n".to_vec(), tiff_eps()] { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"base64":base64_encode(&bytes),"name":"logo.eps"})).unwrap(); - let source = s.doc().unwrap().doc.clone(); - let idml = designcraft_idml::export_idml(&source); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(&idml)).unwrap(); - let names: Vec<_> = z.file_names().filter(|n| n.starts_with("Spreads/")).map(str::to_string).collect(); - let mut xml = String::new(); - for name in names { - z.by_name(&name).unwrap().read_to_string(&mut xml).unwrap(); - } - assert!(xml.contains(" Vec { + let mut tiff = b"II".to_vec(); + tiff.extend(42u16.to_le_bytes()); + tiff.extend(8u32.to_le_bytes()); + tiff.extend(10u16.to_le_bytes()); + for (tag, ty, count, value) in [ + (256u16, 4u16, 1u32, 2u32), + (257, 4, 1, 2), + (258, 3, 3, 134), + (259, 3, 1, 1), + (262, 3, 1, 2), + (273, 4, 1, 140), + (277, 3, 1, 3), + (278, 4, 1, 2), + (279, 4, 1, 12), + (284, 3, 1, 1), + ] { + tiff.extend(tag.to_le_bytes()); + tiff.extend(ty.to_le_bytes()); + tiff.extend(count.to_le_bytes()); + tiff.extend(value.to_le_bytes()); + } + tiff.extend(0u32.to_le_bytes()); + for _ in 0..3 { + tiff.extend(8u16.to_le_bytes()); + } + tiff.extend([255, 0, 0].repeat(4)); + let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EOF\n"; + let mut eps = vec![0xC5, 0xD0, 0xD3, 0xC6]; + for n in [30usize, ps.len(), 0, 0, 30 + ps.len(), tiff.len()] { + eps.extend((n as u32).to_le_bytes()); + } + eps.extend([255, 255]); + eps.extend(ps); + eps.extend(tiff); + eps +} + +#[test] +fn eps_degenerate_bounds_are_rejected_without_edits() { + for bounds in ["0 0 1e-320 1e-320", "0 0 1e308 1e308", "0 0 inf 10"] { + let source = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: {bounds}\n%%EOF\n"); + for command in ["file.place", "place.load"] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let revision = s.doc().unwrap().revision; + assert!( + s.execute(command, &json!({"base64": base64_encode(source.as_bytes()), "name": "bad.eps", "width": 100})).is_err(), + "{command}: {bounds}" + ); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().revision, revision); + assert!(s.loaded.is_none()); + designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); + } + } +} + +#[test] +fn eps_fitting_overflow_is_rejected_before_document_edits() { + let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 0.01 0.01\n%%EOF\n"; + let params = json!({"base64": base64_encode(source), "name": "small.eps"}); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("file.place", &json!({"base64": base64_encode(source), "name": "small.eps", "width": 1e308})).is_err()); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + s.execute("place.load", ¶ms).unwrap(); + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("place.drop", &json!({"rect": [0, 0, 1e308, 1e308]})).is_err()); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + s.execute("place.drop", &json!({"rect": [72, 72, 172, 122]})).unwrap(); + designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); +} + +#[test] +fn eps_table_and_data_merge_reject_fitting_overflow() { + let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 0.01 0.01\n%%EOF\n"; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect":[72,72,372,272],"content":"text"})).unwrap(); + s.execute("text.select", &json!({"story":r["story"],"anchor":0,"focus":0})).unwrap(); + s.execute("table.insert", &json!({"rows":1,"cols":1,"width":1e308})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("table.placeGraphic", &json!({"base64":base64_encode(source),"name":"small.eps","fit":"fill"})).is_err()); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + designcraft_format::load(&designcraft_format::save(&before).unwrap()).unwrap(); + + let path = std::env::temp_dir().join(format!("dc-eps-fit-{}.eps", std::process::id())); + std::fs::write(&path, source).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("data.source.select", &json!({"bytes":base64_encode(format!("@Photo\n{}\n",path.display()).as_bytes()),"name":"source.csv"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect":[0,0,1e308,100],"content":"graphic"})).unwrap(); + s.execute("data.placeholder.add", &json!({"field":"Photo","item":r["id"]})).unwrap(); + let result = s.execute("data.merge", &json!({"fitting":"fillProportionally"})).unwrap(); + assert_eq!(result["missingImages"].as_array().unwrap().len(), 1); + assert!(s.doc().unwrap().doc.assets.is_empty()); + designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); + let _ = std::fs::remove_file(path); +} + +#[test] +fn eps_table_and_data_merge_use_bounding_box_points() { + let dir = std::env::temp_dir().join(format!("dc-eps-routes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (index, bytes) in [b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EOF\n".to_vec(), tiff_eps()].into_iter().enumerate() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72,72,372,272], "content":"text"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor":0, "focus":0})).unwrap(); + s.execute("table.insert", &json!({"rows":1,"cols":1})).unwrap(); + s.execute("table.placeGraphic", &json!({"base64": base64_encode(&bytes), "name":"logo.eps"})).unwrap(); + let table = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); + assert_eq!(table.cells[0].graphic.as_ref().unwrap().size, (100.0, 50.0), "table {index}"); + let path = dir.join(format!("logo-{index}.eps")); + std::fs::write(&path, &bytes).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("data.source.select", &json!({"bytes": base64_encode(format!("@Photo\n{}\n", path.display()).as_bytes()), "name":"source.csv"})) + .unwrap(); + let r = s.execute("frame.create", &json!({"rect":[72,72,372,272],"content":"graphic"})).unwrap(); + s.execute("data.placeholder.add", &json!({"field":"Photo","item":r["id"]})).unwrap(); + let result = s.execute("data.merge", &json!({"fitting":"none"})).unwrap(); + assert!(result["missingImages"].as_array().unwrap().is_empty()); + let g = s + .doc() + .unwrap() + .doc + .spreads + .iter() + .flat_map(|sp| &sp.items) + .find_map(|it| if let Content::Graphic(g) = &it.content { Some(g) } else { None }) + .unwrap(); + assert_eq!(g.size, (100.0, 50.0), "merge {index}"); + designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn eps_idml_roundtrip_preserves_source_and_resource_type() { + use std::io::Read; + for bytes in [b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EOF\n".to_vec(), tiff_eps()] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"base64":base64_encode(&bytes),"name":"logo.eps"})).unwrap(); + let source = s.doc().unwrap().doc.clone(); + let idml = designcraft_idml::export_idml(&source); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&idml)).unwrap(); + let names: Vec<_> = z.file_names().filter(|n| n.starts_with("Spreads/")).map(str::to_string).collect(); + let mut xml = String::new(); + for name in names { + z.by_name(&name).unwrap().read_to_string(&mut xml).unwrap(); + } + assert!(xml.contains(" json!({"dir": dir.join("new").to_string_lossy(), "missingOnly": false}), + "links.update" => json!({"asset": aid.0}), + _ => json!({"asset": aid.0, "path": replacement.to_string_lossy()}), + }; let err = s.execute(command, ¶ms).unwrap_err(); assert!(err.to_string().contains("PDF page 2"), "{command}: {err}"); let st = s.doc().unwrap(); @@ -843,8 +846,10 @@ mod relink_folder_tests { let r = s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap(); assert_eq!(r["relinked"], 2); let st = s.doc().unwrap(); - assert_eq!(st.revision, revision + 1, "the batch commits once"); - assert_eq!(st.history.undo.len(), undo + 1); + // `revision` is drawn from a process-wide counter, so a test running beside this one + // can take the next value: that the batch commits *once* is what the single undo step is. + assert_ne!(st.revision, revision, "the batch commits"); + assert_eq!(st.history.undo.len(), undo + 1, "exactly one undo step"); s.execute("edit.undo", &json!({})).unwrap(); assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before)); let _ = std::fs::remove_dir_all(dir); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 8490fcddf..5e87c5408 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -21,8 +21,9 @@ use designcraft_doc::{AssetId, Document, ParaAttrs, Story}; use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; -/// Latest reader capability. Version 2 adds rendering of retained EPS source assets. -pub const VERSION: u32 = 2; +/// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults; +/// version 3 adds rendering of retained EPS source assets. +pub const VERSION: u32 = 3; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -84,8 +85,9 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("meta.json", deflate).map_err(io)?; // Older readers would silently omit EPS artwork. Preview-only legacy assets and - // documents without EPS still use version 1 so their existing readers keep working. - let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } else { 1 }; + // documents without EPS stay at 2 so their existing readers keep working; writing 1 + // would make a reader re-run the version 2 paragraph upgrade over current defaults. + let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } else { 2 }; 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()))?; if let Some(png) = preview { @@ -439,7 +441,7 @@ mod tests { } #[test] - fn eps_source_requires_new_reader_but_preview_documents_keep_version_one() { + fn eps_source_requires_new_reader_but_preview_documents_keep_the_older_one() { let mut d = Document::new(&NewDocument::default()); let version = |d: &Document| { let bytes = save(d).unwrap(); @@ -448,7 +450,7 @@ mod tests { z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); serde_json::from_str::(&meta).unwrap()["version"].as_u64().unwrap() }; - assert_eq!(version(&d), 1); + assert_eq!(version(&d), 2); let aid = AssetId(d.alloc()); let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec(); d.assets.insert( @@ -464,10 +466,11 @@ mod tests { ..Default::default() }), ); - assert_eq!(version(&d), 2, "sniff source even when legacy metadata says PNG"); + place(&mut d, aid, 0.0); + assert_eq!(version(&d), 3, "sniff source even when legacy metadata says PNG"); assert_eq!(load(&save(&d).unwrap()).unwrap().assets[&aid].data.as_slice(), source); Arc::make_mut(d.assets.get_mut(&aid).unwrap()).data = Arc::new(b"\x89PNG\r\n\x1a\npreview".to_vec()); - assert_eq!(version(&d), 1, "legacy EPS previews stay compatible"); + assert_eq!(version(&d), 2, "legacy EPS previews stay compatible"); } #[test]