mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 22:50:27 -06:00
refactor(format): move the tests module of lib.rs to tests.rs
This commit is contained in:
+1
-747
@@ -393,750 +393,4 @@ fn base64_decode(s: &str) -> Vec<u8> {
|
||||
}
|
||||
|
||||
#[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 {
|
||||
layer_options: None,
|
||||
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(),
|
||||
fill: 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<u8> {
|
||||
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 explicit_all_on_pdf_layers_require_a_capable_reader_only_when_needed() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let aid = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset {
|
||||
id: aid,
|
||||
name: "art.pdf".into(),
|
||||
mime: "application/pdf".into(),
|
||||
data: Arc::new(b"%PDF-1.7".to_vec()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let id = place(&mut d, aid, 0.0);
|
||||
let version = |d: &Document| {
|
||||
let bytes = save(d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
serde_json::from_str::<Value>(&meta).unwrap()["version"].as_u64().unwrap()
|
||||
};
|
||||
assert_eq!(version(&d), 2);
|
||||
d.item_mut(id).unwrap().pdf_layer_override = true;
|
||||
assert_eq!(version(&d), 4);
|
||||
assert!(load(&save(&d).unwrap()).unwrap().item(id).unwrap().pdf_layer_override);
|
||||
d.item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()];
|
||||
assert_eq!(version(&d), 2, "legacy nonempty overrides remain readable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_version_4_reader_keeps_both_explicit_all_on_and_graphic_layer_options() {
|
||||
use designcraft_doc::{Content, GraphicLayerOptions};
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let pdf = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
pdf,
|
||||
Arc::new(Asset {
|
||||
id: pdf,
|
||||
name: "art.pdf".into(),
|
||||
mime: "application/pdf".into(),
|
||||
data: Arc::new(b"%PDF-1.7".to_vec()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let eps = 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(
|
||||
eps,
|
||||
Arc::new(Asset { id: eps, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(source), ..Default::default() }),
|
||||
);
|
||||
let all_on = place(&mut d, pdf, 0.0);
|
||||
let layered = place(&mut d, eps, 0.0);
|
||||
assert_eq!(required_version(&d), 3, "neither feature: EPS documents keep version 3");
|
||||
let options = GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() };
|
||||
let set_options = |d: &mut Document, value: Option<GraphicLayerOptions>| {
|
||||
let Some(Content::Graphic(g)) = d.item_mut(layered).map(|it| &mut it.content) else { panic!("placed graphic") };
|
||||
g.layer_options = value;
|
||||
};
|
||||
set_options(&mut d, Some(options.clone()));
|
||||
d.item_mut(all_on).unwrap().pdf_layer_override = true;
|
||||
let bytes = save(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&meta).unwrap()["version"], 4);
|
||||
let reopened = load(&bytes).unwrap();
|
||||
let it = reopened.item(all_on).unwrap();
|
||||
assert!(it.pdf_layer_override && it.pdf_hidden_layers.is_empty(), "explicit all-on survives");
|
||||
assert_eq!(reopened.item(layered).unwrap().graphic().unwrap().layer_options.as_ref(), Some(&options), "graphic layer options survive");
|
||||
assert_eq!(required_version(&reopened), 4);
|
||||
// Either feature alone needs version 4; dropping both restores the older requirement.
|
||||
set_options(&mut d, None);
|
||||
assert_eq!(required_version(&d), 4, "explicit all-on alone");
|
||||
d.item_mut(all_on).unwrap().pdf_layer_override = false;
|
||||
assert_eq!(required_version(&d), 3);
|
||||
set_options(&mut d, Some(options));
|
||||
assert_eq!(required_version(&d), 4, "graphic layer options alone");
|
||||
set_options(&mut d, None);
|
||||
d.assets.remove(&eps);
|
||||
d.remove_item(layered).unwrap();
|
||||
assert_eq!(required_version(&d), 2, "neither feature and no EPS: version 2");
|
||||
// An explicit all-on choice on a parent spread also needs the version 4 reader.
|
||||
let mut parent_item = (*d.remove_item(all_on).unwrap()).clone();
|
||||
parent_item.pdf_layer_override = true;
|
||||
d.insert_item(SpreadRef::Parent(0), parent_item, None).unwrap();
|
||||
assert_eq!(required_version(&d), 4, "parent-spread placements are not skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
serde_json::from_str::<Value>(&meta).unwrap()["version"].as_u64().unwrap()
|
||||
};
|
||||
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(
|
||||
aid,
|
||||
Arc::new(Asset {
|
||||
id: aid,
|
||||
name: "logo.eps".into(),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: Arc::new(source.clone()),
|
||||
pixels: Some((40, 20)),
|
||||
page: 0,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
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), 2, "legacy EPS previews stay compatible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_place_picks_the_oldest_reader_that_keeps_the_document() {
|
||||
use designcraft_doc::{Content, GraphicLayerOptions};
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
assert_eq!(required_version(&d), 2, "plain documents keep the version 2 reader");
|
||||
let aid = AssetId(d.alloc());
|
||||
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec();
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset { id: aid, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(eps), ..Default::default() }),
|
||||
);
|
||||
let id = place(&mut d, aid, 0.0);
|
||||
assert_eq!(required_version(&d), 3, "EPS sources alone keep the version 3 reader");
|
||||
let Some(Content::Graphic(g)) = d.item_mut(id).map(|it| &mut it.content) else { panic!("placed graphic") };
|
||||
g.layer_options = Some(GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() });
|
||||
assert_eq!(required_version(&d), 4, "retained graphic layer options need version 4 even with EPS");
|
||||
assert_eq!(required_version(&load(&save(&d).unwrap()).unwrap()), 4);
|
||||
}
|
||||
|
||||
#[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<u8> {
|
||||
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<bool>) -> 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<bool> = 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);
|
||||
}
|
||||
|
||||
/// Assets over the zip size limit are written with zip64 sizes (scaled down here: the real
|
||||
/// threshold is near 4 GiB); smaller ones are written exactly as before.
|
||||
#[test]
|
||||
fn large_assets_are_written_with_zip64_sizes_and_open_again() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let aid = png_asset(&mut d, &[5; 64]);
|
||||
place(&mut d, aid, 0.0);
|
||||
let normal = save(&d).unwrap();
|
||||
assert_eq!(normal, save_with_large_entries_from(&d, None, 65).unwrap(), "an asset under the threshold is unchanged");
|
||||
let large = save_with_large_entries_from(&d, None, 64).unwrap();
|
||||
assert!(large.len() > normal.len(), "the asset entry carries zip64 sizes");
|
||||
assert_eq!(*load(&large).unwrap().assets[&aid].data, vec![5; 64]);
|
||||
}
|
||||
|
||||
#[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<_>>(), 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<AssetId> = (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<Option<&str>> = 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 equal_size_archive_assets_preserve_content_and_share_only_identical_buffers() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let mut pairs = Vec::new();
|
||||
for n in 0..128u64 {
|
||||
let mut bytes = vec![42; 1024];
|
||||
bytes[1016..].copy_from_slice(&n.to_le_bytes());
|
||||
let a = png_asset(&mut d, &bytes);
|
||||
let b = png_asset(&mut d, &bytes);
|
||||
// Distinct links must remain independently relinkable even when their bytes match.
|
||||
Arc::make_mut(d.assets.get_mut(&a).unwrap()).link = Some(format!("/a/{n}.png"));
|
||||
Arc::make_mut(d.assets.get_mut(&b).unwrap()).link = Some(format!("/b/{n}.png"));
|
||||
place(&mut d, a, 1.0);
|
||||
place(&mut d, b, 2.0);
|
||||
pairs.push((a, b));
|
||||
}
|
||||
let back = load(&save_uncompacted(&d)).unwrap();
|
||||
assert_eq!(back.assets.len(), 256);
|
||||
for (n, (a, b)) in pairs.into_iter().enumerate() {
|
||||
assert_eq!(back.assets[&a].data[1016..], (n as u64).to_le_bytes());
|
||||
assert!(Arc::ptr_eq(&back.assets[&a].data, &back.assets[&b].data));
|
||||
assert_eq!(back.assets[&a].link, d.assets[&a].link);
|
||||
assert_eq!(back.assets[&b].link, d.assets[&b].link);
|
||||
}
|
||||
assert_eq!(back.spreads, d.spreads);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_files_with_one_asset_per_placement_open_with_one() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let ids: Vec<AssetId> = (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<TabStop> = (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 the_preview_is_read_back_without_the_document() {
|
||||
let d = Document::new(&NewDocument::default());
|
||||
let png = b"\x89PNG fake".to_vec();
|
||||
let bytes = save_with_preview(&d, Some(&png)).unwrap();
|
||||
assert_eq!(read_preview(Cursor::new(&bytes)), Some(png));
|
||||
assert!(load(&bytes).is_ok(), "the preview doesn't get in the way of opening");
|
||||
// Files without one, legacy JSON and garbage have none.
|
||||
assert_eq!(read_preview(Cursor::new(save(&d).unwrap())), None);
|
||||
assert_eq!(read_preview(Cursor::new(b"{}".to_vec())), None);
|
||||
assert_eq!(read_preview(Cursor::new(Vec::new())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_that_inflates_past_its_declared_size_is_refused() {
|
||||
use std::io::Write;
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
z.start_file(PREVIEW, opts).unwrap();
|
||||
z.write_all(&vec![0u8; usize::try_from(MAX_PREVIEW_BYTES).unwrap() + 4096]).unwrap();
|
||||
let mut bytes = z.finish().unwrap().into_inner();
|
||||
// Declare the entry tiny in the local header and the central directory.
|
||||
let patch = |bytes: &mut Vec<u8>, sig: &[u8], at: usize| {
|
||||
let start = bytes.windows(4).position(|w| w == sig).unwrap();
|
||||
bytes[start + at..start + at + 4].copy_from_slice(&16u32.to_le_bytes());
|
||||
};
|
||||
patch(&mut bytes, b"PK\x03\x04", 22);
|
||||
patch(&mut bytes, b"PK\x01\x02", 24);
|
||||
assert_eq!(read_preview(Cursor::new(&bytes)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_and_newer_versions() {
|
||||
assert!(load(b"nope").is_err());
|
||||
for version in [99u64, u64::from(u32::MAX) + 2] {
|
||||
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(json!({"version": version}).to_string().as_bytes()).unwrap();
|
||||
z.finish().unwrap();
|
||||
}
|
||||
assert!(matches!(load(&buf.into_inner()), Err(FormatError::TooNew(v)) if v == version));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
// Pin legacy paragraph defaults so both formats isolate nullable overrides.
|
||||
for style in &mut d.styles_mut().paragraph {
|
||||
style.para.keep_all_lines = Some(true);
|
||||
style.para.drop_cap_align_left = Some(false);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/// Write `len` bytes of `fill` to the current entry of `z`.
|
||||
fn write_fill(z: &mut zip::ZipWriter<Cursor<Vec<u8>>>, len: usize, fill: u8) {
|
||||
let chunk = vec![fill; 1 << 20];
|
||||
let mut left = len;
|
||||
while left > 0 {
|
||||
let n = left.min(chunk.len());
|
||||
z.write_all(&chunk[..n]).unwrap();
|
||||
left -= n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the archive claim that entry `name` expands to `size` bytes, in both its local header
|
||||
/// and its central directory record (the archive has no ZIP64 fields or data descriptors).
|
||||
fn claim_size(bytes: &mut [u8], name: &str, size: u32) {
|
||||
let mut patched = 0;
|
||||
for i in 0..bytes.len().saturating_sub(46) {
|
||||
let (size_at, len_at, name_at) = match &bytes[i..i + 4] {
|
||||
b"PK\x03\x04" => (22, 26, 30),
|
||||
b"PK\x01\x02" => (24, 28, 46),
|
||||
_ => continue,
|
||||
};
|
||||
let len = u16::from_le_bytes([bytes[i + len_at], bytes[i + len_at + 1]]) as usize;
|
||||
if bytes.get(i + name_at..i + name_at + len) == Some(name.as_bytes()) {
|
||||
bytes[i + size_at..i + size_at + 4].copy_from_slice(&size.to_le_bytes());
|
||||
patched += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(patched, 2, "{name} not found in both headers");
|
||||
}
|
||||
|
||||
/// A small archive whose `document.json` expands past the 256 MiB limit while claiming to
|
||||
/// hold one byte is refused once the limit is reached, with an error that says so, instead of
|
||||
/// being read whole into memory.
|
||||
#[test]
|
||||
fn oversized_document_json_is_refused() {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
write_fill(&mut z, (256 << 20) + 1, b' ');
|
||||
let mut bytes = z.finish().unwrap().into_inner();
|
||||
assert!(bytes.len() < 4 << 20, "{}", bytes.len());
|
||||
claim_size(&mut bytes, "document.json", 1);
|
||||
let Err(err) = load(&bytes) else { panic!("an oversized document.json loaded") };
|
||||
assert!(err.to_string().contains("limit"), "{err}");
|
||||
}
|
||||
|
||||
/// An embedded file that expands past the 1 GiB limit (and says so) is refused too.
|
||||
#[test]
|
||||
fn oversized_asset_is_refused() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let aid = png_asset(&mut d, &[1]);
|
||||
place(&mut d, aid, 0.0);
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
z.start_file(format!("assets/{}.png", aid.0), zip::write::SimpleFileOptions::default()).unwrap();
|
||||
write_fill(&mut z, (1 << 30) + 1, 0);
|
||||
let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an oversized asset loaded") };
|
||||
assert!(err.to_string().contains("limit"), "{err}");
|
||||
}
|
||||
|
||||
/// An archive with more entries than any document needs is refused before any is read.
|
||||
#[test]
|
||||
fn too_many_entries_are_refused() {
|
||||
let d = Document::new(&NewDocument::default());
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
z.start_file("document.json", stored).unwrap();
|
||||
z.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
for i in 0..100_000 {
|
||||
z.start_file(format!("junk/{i}"), stored).unwrap();
|
||||
}
|
||||
let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an archive with 100 001 entries loaded") };
|
||||
assert!(err.to_string().contains("entries"), "{err}");
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
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 {
|
||||
layer_options: None,
|
||||
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(),
|
||||
fill: 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<u8> {
|
||||
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 explicit_all_on_pdf_layers_require_a_capable_reader_only_when_needed() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let aid = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset {
|
||||
id: aid,
|
||||
name: "art.pdf".into(),
|
||||
mime: "application/pdf".into(),
|
||||
data: Arc::new(b"%PDF-1.7".to_vec()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let id = place(&mut d, aid, 0.0);
|
||||
let version = |d: &Document| {
|
||||
let bytes = save(d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
serde_json::from_str::<Value>(&meta).unwrap()["version"].as_u64().unwrap()
|
||||
};
|
||||
assert_eq!(version(&d), 2);
|
||||
d.item_mut(id).unwrap().pdf_layer_override = true;
|
||||
assert_eq!(version(&d), 4);
|
||||
assert!(load(&save(&d).unwrap()).unwrap().item(id).unwrap().pdf_layer_override);
|
||||
d.item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()];
|
||||
assert_eq!(version(&d), 2, "legacy nonempty overrides remain readable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_version_4_reader_keeps_both_explicit_all_on_and_graphic_layer_options() {
|
||||
use designcraft_doc::{Content, GraphicLayerOptions};
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let pdf = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
pdf,
|
||||
Arc::new(Asset {
|
||||
id: pdf,
|
||||
name: "art.pdf".into(),
|
||||
mime: "application/pdf".into(),
|
||||
data: Arc::new(b"%PDF-1.7".to_vec()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let eps = 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(
|
||||
eps,
|
||||
Arc::new(Asset { id: eps, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(source), ..Default::default() }),
|
||||
);
|
||||
let all_on = place(&mut d, pdf, 0.0);
|
||||
let layered = place(&mut d, eps, 0.0);
|
||||
assert_eq!(required_version(&d), 3, "neither feature: EPS documents keep version 3");
|
||||
let options = GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() };
|
||||
let set_options = |d: &mut Document, value: Option<GraphicLayerOptions>| {
|
||||
let Some(Content::Graphic(g)) = d.item_mut(layered).map(|it| &mut it.content) else { panic!("placed graphic") };
|
||||
g.layer_options = value;
|
||||
};
|
||||
set_options(&mut d, Some(options.clone()));
|
||||
d.item_mut(all_on).unwrap().pdf_layer_override = true;
|
||||
let bytes = save(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&meta).unwrap()["version"], 4);
|
||||
let reopened = load(&bytes).unwrap();
|
||||
let it = reopened.item(all_on).unwrap();
|
||||
assert!(it.pdf_layer_override && it.pdf_hidden_layers.is_empty(), "explicit all-on survives");
|
||||
assert_eq!(reopened.item(layered).unwrap().graphic().unwrap().layer_options.as_ref(), Some(&options), "graphic layer options survive");
|
||||
assert_eq!(required_version(&reopened), 4);
|
||||
// Either feature alone needs version 4; dropping both restores the older requirement.
|
||||
set_options(&mut d, None);
|
||||
assert_eq!(required_version(&d), 4, "explicit all-on alone");
|
||||
d.item_mut(all_on).unwrap().pdf_layer_override = false;
|
||||
assert_eq!(required_version(&d), 3);
|
||||
set_options(&mut d, Some(options));
|
||||
assert_eq!(required_version(&d), 4, "graphic layer options alone");
|
||||
set_options(&mut d, None);
|
||||
d.assets.remove(&eps);
|
||||
d.remove_item(layered).unwrap();
|
||||
assert_eq!(required_version(&d), 2, "neither feature and no EPS: version 2");
|
||||
// An explicit all-on choice on a parent spread also needs the version 4 reader.
|
||||
let mut parent_item = (*d.remove_item(all_on).unwrap()).clone();
|
||||
parent_item.pdf_layer_override = true;
|
||||
d.insert_item(SpreadRef::Parent(0), parent_item, None).unwrap();
|
||||
assert_eq!(required_version(&d), 4, "parent-spread placements are not skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
serde_json::from_str::<Value>(&meta).unwrap()["version"].as_u64().unwrap()
|
||||
};
|
||||
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(
|
||||
aid,
|
||||
Arc::new(Asset {
|
||||
id: aid,
|
||||
name: "logo.eps".into(),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: Arc::new(source.clone()),
|
||||
pixels: Some((40, 20)),
|
||||
page: 0,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
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), 2, "legacy EPS previews stay compatible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_place_picks_the_oldest_reader_that_keeps_the_document() {
|
||||
use designcraft_doc::{Content, GraphicLayerOptions};
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
assert_eq!(required_version(&d), 2, "plain documents keep the version 2 reader");
|
||||
let aid = AssetId(d.alloc());
|
||||
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec();
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset { id: aid, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(eps), ..Default::default() }),
|
||||
);
|
||||
let id = place(&mut d, aid, 0.0);
|
||||
assert_eq!(required_version(&d), 3, "EPS sources alone keep the version 3 reader");
|
||||
let Some(Content::Graphic(g)) = d.item_mut(id).map(|it| &mut it.content) else { panic!("placed graphic") };
|
||||
g.layer_options = Some(GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() });
|
||||
assert_eq!(required_version(&d), 4, "retained graphic layer options need version 4 even with EPS");
|
||||
assert_eq!(required_version(&load(&save(&d).unwrap()).unwrap()), 4);
|
||||
}
|
||||
|
||||
#[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<u8> {
|
||||
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<bool>) -> 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<bool> = 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);
|
||||
}
|
||||
|
||||
/// Assets over the zip size limit are written with zip64 sizes (scaled down here: the real
|
||||
/// threshold is near 4 GiB); smaller ones are written exactly as before.
|
||||
#[test]
|
||||
fn large_assets_are_written_with_zip64_sizes_and_open_again() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let aid = png_asset(&mut d, &[5; 64]);
|
||||
place(&mut d, aid, 0.0);
|
||||
let normal = save(&d).unwrap();
|
||||
assert_eq!(normal, save_with_large_entries_from(&d, None, 65).unwrap(), "an asset under the threshold is unchanged");
|
||||
let large = save_with_large_entries_from(&d, None, 64).unwrap();
|
||||
assert!(large.len() > normal.len(), "the asset entry carries zip64 sizes");
|
||||
assert_eq!(*load(&large).unwrap().assets[&aid].data, vec![5; 64]);
|
||||
}
|
||||
|
||||
#[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<_>>(), 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<AssetId> = (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<Option<&str>> =
|
||||
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 equal_size_archive_assets_preserve_content_and_share_only_identical_buffers() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let mut pairs = Vec::new();
|
||||
for n in 0..128u64 {
|
||||
let mut bytes = vec![42; 1024];
|
||||
bytes[1016..].copy_from_slice(&n.to_le_bytes());
|
||||
let a = png_asset(&mut d, &bytes);
|
||||
let b = png_asset(&mut d, &bytes);
|
||||
// Distinct links must remain independently relinkable even when their bytes match.
|
||||
Arc::make_mut(d.assets.get_mut(&a).unwrap()).link = Some(format!("/a/{n}.png"));
|
||||
Arc::make_mut(d.assets.get_mut(&b).unwrap()).link = Some(format!("/b/{n}.png"));
|
||||
place(&mut d, a, 1.0);
|
||||
place(&mut d, b, 2.0);
|
||||
pairs.push((a, b));
|
||||
}
|
||||
let back = load(&save_uncompacted(&d)).unwrap();
|
||||
assert_eq!(back.assets.len(), 256);
|
||||
for (n, (a, b)) in pairs.into_iter().enumerate() {
|
||||
assert_eq!(back.assets[&a].data[1016..], (n as u64).to_le_bytes());
|
||||
assert!(Arc::ptr_eq(&back.assets[&a].data, &back.assets[&b].data));
|
||||
assert_eq!(back.assets[&a].link, d.assets[&a].link);
|
||||
assert_eq!(back.assets[&b].link, d.assets[&b].link);
|
||||
}
|
||||
assert_eq!(back.spreads, d.spreads);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_files_with_one_asset_per_placement_open_with_one() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let ids: Vec<AssetId> = (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<TabStop> = (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 the_preview_is_read_back_without_the_document() {
|
||||
let d = Document::new(&NewDocument::default());
|
||||
let png = b"\x89PNG fake".to_vec();
|
||||
let bytes = save_with_preview(&d, Some(&png)).unwrap();
|
||||
assert_eq!(read_preview(Cursor::new(&bytes)), Some(png));
|
||||
assert!(load(&bytes).is_ok(), "the preview doesn't get in the way of opening");
|
||||
// Files without one, legacy JSON and garbage have none.
|
||||
assert_eq!(read_preview(Cursor::new(save(&d).unwrap())), None);
|
||||
assert_eq!(read_preview(Cursor::new(b"{}".to_vec())), None);
|
||||
assert_eq!(read_preview(Cursor::new(Vec::new())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_that_inflates_past_its_declared_size_is_refused() {
|
||||
use std::io::Write;
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
z.start_file(PREVIEW, opts).unwrap();
|
||||
z.write_all(&vec![0u8; usize::try_from(MAX_PREVIEW_BYTES).unwrap() + 4096]).unwrap();
|
||||
let mut bytes = z.finish().unwrap().into_inner();
|
||||
// Declare the entry tiny in the local header and the central directory.
|
||||
let patch = |bytes: &mut Vec<u8>, sig: &[u8], at: usize| {
|
||||
let start = bytes.windows(4).position(|w| w == sig).unwrap();
|
||||
bytes[start + at..start + at + 4].copy_from_slice(&16u32.to_le_bytes());
|
||||
};
|
||||
patch(&mut bytes, b"PK\x03\x04", 22);
|
||||
patch(&mut bytes, b"PK\x01\x02", 24);
|
||||
assert_eq!(read_preview(Cursor::new(&bytes)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_and_newer_versions() {
|
||||
assert!(load(b"nope").is_err());
|
||||
for version in [99u64, u64::from(u32::MAX) + 2] {
|
||||
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(json!({"version": version}).to_string().as_bytes()).unwrap();
|
||||
z.finish().unwrap();
|
||||
}
|
||||
assert!(matches!(load(&buf.into_inner()), Err(FormatError::TooNew(v)) if v == version));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
// Pin legacy paragraph defaults so both formats isolate nullable overrides.
|
||||
for style in &mut d.styles_mut().paragraph {
|
||||
style.para.keep_all_lines = Some(true);
|
||||
style.para.drop_cap_align_left = Some(false);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/// Write `len` bytes of `fill` to the current entry of `z`.
|
||||
fn write_fill(z: &mut zip::ZipWriter<Cursor<Vec<u8>>>, len: usize, fill: u8) {
|
||||
let chunk = vec![fill; 1 << 20];
|
||||
let mut left = len;
|
||||
while left > 0 {
|
||||
let n = left.min(chunk.len());
|
||||
z.write_all(&chunk[..n]).unwrap();
|
||||
left -= n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the archive claim that entry `name` expands to `size` bytes, in both its local header
|
||||
/// and its central directory record (the archive has no ZIP64 fields or data descriptors).
|
||||
fn claim_size(bytes: &mut [u8], name: &str, size: u32) {
|
||||
let mut patched = 0;
|
||||
for i in 0..bytes.len().saturating_sub(46) {
|
||||
let (size_at, len_at, name_at) = match &bytes[i..i + 4] {
|
||||
b"PK\x03\x04" => (22, 26, 30),
|
||||
b"PK\x01\x02" => (24, 28, 46),
|
||||
_ => continue,
|
||||
};
|
||||
let len = u16::from_le_bytes([bytes[i + len_at], bytes[i + len_at + 1]]) as usize;
|
||||
if bytes.get(i + name_at..i + name_at + len) == Some(name.as_bytes()) {
|
||||
bytes[i + size_at..i + size_at + 4].copy_from_slice(&size.to_le_bytes());
|
||||
patched += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(patched, 2, "{name} not found in both headers");
|
||||
}
|
||||
|
||||
/// A small archive whose `document.json` expands past the 256 MiB limit while claiming to
|
||||
/// hold one byte is refused once the limit is reached, with an error that says so, instead of
|
||||
/// being read whole into memory.
|
||||
#[test]
|
||||
fn oversized_document_json_is_refused() {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
write_fill(&mut z, (256 << 20) + 1, b' ');
|
||||
let mut bytes = z.finish().unwrap().into_inner();
|
||||
assert!(bytes.len() < 4 << 20, "{}", bytes.len());
|
||||
claim_size(&mut bytes, "document.json", 1);
|
||||
let Err(err) = load(&bytes) else { panic!("an oversized document.json loaded") };
|
||||
assert!(err.to_string().contains("limit"), "{err}");
|
||||
}
|
||||
|
||||
/// An embedded file that expands past the 1 GiB limit (and says so) is refused too.
|
||||
#[test]
|
||||
fn oversized_asset_is_refused() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let aid = png_asset(&mut d, &[1]);
|
||||
place(&mut d, aid, 0.0);
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
z.start_file(format!("assets/{}.png", aid.0), zip::write::SimpleFileOptions::default()).unwrap();
|
||||
write_fill(&mut z, (1 << 30) + 1, 0);
|
||||
let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an oversized asset loaded") };
|
||||
assert!(err.to_string().contains("limit"), "{err}");
|
||||
}
|
||||
|
||||
/// An archive with more entries than any document needs is refused before any is read.
|
||||
#[test]
|
||||
fn too_many_entries_are_refused() {
|
||||
let d = Document::new(&NewDocument::default());
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
z.start_file("document.json", stored).unwrap();
|
||||
z.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
for i in 0..100_000 {
|
||||
z.start_file(format!("junk/{i}"), stored).unwrap();
|
||||
}
|
||||
let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an archive with 100 001 entries loaded") };
|
||||
assert!(err.to_string().contains("entries"), "{err}");
|
||||
}
|
||||
Reference in New Issue
Block a user