Files
designcraft/crates/format/src/tests.rs
T
jayzed80 5c484618ca Save: a TIFF, BMP or PSD too big for opening to inflate is stored as is, so the document reopens (#2283) (#2701)
save deflated every asset that isn't PNG, JPEG, GIF or WebP, with no size
cap, while opening refuses a deflated asset over 1 GiB or deflated entries
over 4 GiB together. An asset past either cap is now stored as is, which
opening reads at any size. Smaller assets are deflated as before, and the
zip bomb guard on load is unchanged.
2026-11-12 15:53:23 +00:00

1019 lines
47 KiB
Rust

use super::*;
use designcraft_doc::build::NewDocument;
use designcraft_doc::geom::Rect;
use designcraft_doc::{Asset, ParaFormat, SpreadRef};
/// An entry that fails after inflating (a bad checksum) still uses up the archive's byte budget.
#[test]
fn a_failed_read_is_charged_to_the_budget() {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
z.start_file("a", stored).unwrap();
z.write_all(&[7u8; 1000]).unwrap();
let mut bytes = z.finish().unwrap().into_inner();
// Flip a data byte: the entry reads in full, then fails its checksum.
// The payload is the only run of 1,000 consecutive 7s in the archive.
let start = bytes.windows(1000).position(|w| w.iter().all(|b| *b == 7)).expect("payload");
bytes[start] = 8;
let mut archive = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
let (mut left, mut stored_left) = (5000, 5000);
assert_eq!(read_entry(&mut archive, "a", 4000, &mut left, &mut stored_left).unwrap(), None);
// A failed stored read is charged to the stored budget only.
assert_eq!((left, stored_left), (5000, 4000));
}
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()
}
/// A `.designcraft` with the given asset entries, each stored as is or deflated.
fn archive_with(d: &Document, entries: &[(AssetId, &[u8], bool)]) -> Vec<u8> {
let mut buf = Cursor::new(Vec::new());
let mut z = zip::ZipWriter::new(&mut buf);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("mimetype", stored).unwrap();
z.write_all(MIME.as_bytes()).unwrap();
z.start_file("meta.json", deflate).unwrap();
z.write_all(json!({"format": "designcraft", "version": VERSION}).to_string().as_bytes()).unwrap();
z.start_file("document.json", deflate).unwrap();
z.write_all(&serde_json::to_vec(d).unwrap()).unwrap();
for (id, data, as_is) in entries {
z.start_file(format!("assets/{}.png", id.0), if *as_is { stored } else { deflate }).unwrap();
z.write_all(data).unwrap();
}
z.finish().unwrap();
buf.into_inner()
}
/// Bytes that don't compress (a simple generator, so the test needs no crate).
fn noise(n: usize) -> Vec<u8> {
let mut x: u32 = 0x9e37_79b9;
(0..n)
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
(x >> 24) as u8
})
.collect()
}
/// #2166: saving has no size cap, so opening must keep every saved document openable. A stored
/// entry (how placed images are saved) can't expand and opens at any size; the total budget is
/// never below the archive's own size. Deflated entries stay capped, so a zip bomb is refused.
#[test]
fn stored_assets_open_at_any_size_while_deflated_ones_stay_capped() {
let mut d = Document::new(&NewDocument::default());
let big = noise(50_000);
let a = png_asset(&mut d, &big);
let b = png_asset(&mut d, &big);
d.assets.values_mut().for_each(|a| Arc::make_mut(a).data = Arc::new(Vec::new()));
place(&mut d, a, 0.0);
place(&mut d, b, 0.0);
// A budget that fits the document's JSON but not the two 50 000-byte assets together.
let json_len = serde_json::to_vec(&d).unwrap().len() as u64;
let tight = Limits { json: 256 << 20, asset: 1_000, total: json_len + 10_000 };
assert!(tight.total < 100_000, "the JSON is {json_len} bytes; the budget must stay under the two assets");
// Two stored assets, each over the asset cap and together over the total: both open.
let bytes = archive_with(&d, &[(a, &big, true), (b, &big, true)]);
let doc = load_zip_within(&bytes, tight).expect("stored entries open at their own size");
assert_eq!(doc.assets[&a].data.len(), 50_000);
// (Identical bytes come back as one asset: `load` merges duplicates.)
assert_eq!(doc.assets.len(), 1);
assert!(doc.all_items().iter().filter_map(|i| doc.item(*i)?.graphic()).all(|g| g.asset == a), "both placements show it");
// A deflated entry past the cap is refused, whether it really is that big ...
let bytes = archive_with(&d, &[(a, &big, false), (b, &big, true)]);
let e = load_zip_within(&bytes, tight).expect_err("a deflated entry over the cap").to_string();
assert!(e.contains("larger than the 1000-byte limit"), "{e}");
// ... or claims to be (a few KB that expand to megabytes).
let bomb = vec![0u8; 3 << 20];
let bytes = archive_with(&d, &[(a, &bomb, false), (b, &big, true)]);
assert!(bytes.len() < 64 << 10, "the bomb is small on disk: {}", bytes.len());
assert!(load_zip_within(&bytes, tight).is_err(), "a zip bomb is refused");
// The default limits read the same stored archive, and what `save` writes round-trips.
let bytes = archive_with(&d, &[(a, &big, true), (b, &big, true)]);
assert_eq!(load_zip(&bytes).unwrap().assets[&a].data.len(), 50_000);
Arc::make_mut(d.assets.get_mut(&a).unwrap()).data = Arc::new(big.clone());
Arc::make_mut(d.assets.get_mut(&b).unwrap()).data = Arc::new(big.clone());
let saved = save(&d).unwrap();
let doc = load_zip_within(&saved, tight).expect("what save writes opens under a cap smaller than its images");
// (`save` merges the two identical assets into one.)
assert!(!doc.assets.is_empty() && doc.assets.values().all(|x| x.data.len() == 50_000), "every saved image is back");
}
fn tiff_asset(d: &mut Document, data: Vec<u8>) -> AssetId {
let aid = AssetId(d.alloc());
let a = Asset { id: aid, name: "x.tif".into(), mime: "image/tiff".into(), data: Arc::new(data), pixels: Some((1, 1)), ..Default::default() };
d.assets.insert(aid, Arc::new(a));
place(d, aid, 0.0);
aid
}
fn is_deflated(bytes: &[u8], aid: AssetId) -> bool {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
z.by_name(&format!("assets/{}.tif", aid.0)).unwrap().compression() == zip::CompressionMethod::Deflated
}
/// #2283: `save` deflates a TIFF, BMP or PSD, and opening caps what a deflated asset (and all of
/// them together) may expand to. One past either cap is stored as is, so the document opens again.
#[test]
fn assets_too_big_to_inflate_are_stored_so_the_document_reopens() {
let mut d = Document::new(&NewDocument::default());
let big = tiff_asset(&mut d, noise(5_000));
let b = tiff_asset(&mut d, noise(3_000));
let c = tiff_asset(&mut d, noise(3_000).into_iter().rev().collect());
let small = tiff_asset(&mut d, vec![0; 500]);
// Room for the JSON, the small asset and one 3 000-byte asset, not two.
let json_len = serde_json::to_vec_pretty(&d).unwrap().len() as u64;
let tight = Limits { json: 256 << 20, asset: 4_000, total: json_len + 500 + 500 + 4_000 };
let saved = save_within(&d, None, LARGE_ENTRY, tight).unwrap();
let back = load_zip_within(&saved, tight).expect("a saved document opens under the limits it was saved for");
for aid in [big, b, c, small] {
assert_eq!(back.assets[&aid].data, d.assets[&aid].data, "asset {} round-trips", aid.0);
}
assert!(!is_deflated(&saved, big), "an asset over the per-asset cap is stored");
assert!(!(is_deflated(&saved, b) && is_deflated(&saved, c)), "the two together would pass the total budget");
// Within the limits nothing changes: everything still deflates under the defaults.
let normal = save(&d).unwrap();
assert!([big, b, c, small].iter().all(|a| is_deflated(&normal, *a)));
}
#[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);
}
/// A format-1 file with many styles upgrades in time linear in them (each style's chain was
/// found by searching every style, twice over).
#[test]
fn format_1_upgrade_of_many_styles_is_linear() {
let mut d = Document::new(&NewDocument::default());
let st = d.styles_mut();
for i in 0..20_000 {
st.paragraph.push(para_style(&format!("S{i}"), (i % 2 == 1).then(|| format!("S{}", i - 1)).as_deref(), None));
}
let bytes = save_v1(&d);
let t = std::time::Instant::now();
let back = load(&bytes).unwrap();
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
assert_eq!(back.styles.para("S0").unwrap().para.keep_all_lines, Some(true));
assert_eq!(back.styles.para("S1").unwrap().para.keep_all_lines, None, "based on S0, which is pinned");
}
/// 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)),
kenten_size: Some(Some(14.0)),
kenten_fill_tint: Some(Some(0.3)),
kenten_stroke_tint: Some(Some(0.4)),
kenten_stroke_weight: Some(Some(2.0)),
ruby_font_size: Some(Some(14.0)),
ruby_fill_tint: Some(Some(0.3)),
ruby_stroke_tint: Some(Some(0.4)),
ruby_stroke_weight: Some(Some(2.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())),
list_tab: Some(Some(36.0)),
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),
kenten_size: Some(None),
kenten_fill_tint: Some(None),
kenten_stroke_tint: Some(None),
kenten_stroke_weight: Some(None),
ruby_font_size: Some(None),
ruby_fill_tint: Some(None),
ruby_stroke_tint: Some(None),
ruby_stroke_weight: Some(None),
..Default::default()
};
let clear_para =
ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), list_tab: 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);
assert_eq!(expected.0.list_tab, None);
assert_eq!(expected.1.kenten_size, None);
assert_eq!(expected.1.kenten_fill_tint, None);
assert_eq!(expected.1.kenten_stroke_tint, None);
assert_eq!(expected.1.kenten_stroke_weight, None);
assert_eq!(expected.1.ruby_font_size, None);
assert_eq!(expected.1.ruby_fill_tint, None);
assert_eq!(expected.1.ruby_stroke_tint, None);
assert_eq!(expected.1.ruby_stroke_weight, 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}");
}
/// Every item id in `d` (spreads, parents, anchored), in walk order.
fn all_item_ids(d: &Document) -> Vec<u64> {
let mut ids = Vec::new();
for sp in d.spreads.iter().chain(d.parents.iter()) {
for it in &sp.items {
it.walk(&mut |i| ids.push(i.id.0));
}
}
ids.extend(d.anchored_items().iter().map(|(_, i)| i.id.0));
ids
}
fn set_item_id(d: &mut Document, index: usize, id: designcraft_doc::ItemId) {
let sp = Arc::make_mut(&mut d.spreads[0]);
Arc::make_mut(&mut sp.items[index]).id = id;
}
#[test]
fn files_with_duplicate_item_ids_open_with_unique_ids() {
let mut d = Document::new(&NewDocument::default());
let one = png_asset(&mut d, &[1]);
let two = png_asset(&mut d, &[2]);
let a = place(&mut d, one, 0.0);
place(&mut d, two, 0.0);
set_item_id(&mut d, 1, a);
let back = load(&save(&d).unwrap()).unwrap();
back.check().unwrap();
let ids = all_item_ids(&back);
assert_eq!(ids.len(), 2);
assert_ne!(ids[0], ids[1]);
assert!(back.next_id >= ids[0].max(ids[1]));
let assets: Vec<_> = back.spreads[0].items.iter().map(|i| i.graphic().unwrap().asset).collect();
assert_eq!(assets, vec![one, two]);
assert_eq!(back.item(a).and_then(|i| i.graphic()).map(|g| g.asset), Some(one));
}
#[test]
fn duplicate_text_frame_ids_keep_each_story_in_its_own_frame() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "first", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 210.0, 200.0, 400.0), lid, "second", ParaFormat::default()).unwrap();
let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 210.0, 400.0, 400.0), lid, "", ParaFormat::default()).unwrap();
d.thread(b, c).unwrap();
// The second story's first frame carries the first frame's id.
set_item_id(&mut d, 1, a);
let st = d.story_mut(sb).unwrap();
assert_eq!(st.frames, vec![b, c]);
st.frames[0] = a;
let back = load(&save(&d).unwrap()).unwrap();
back.check().unwrap();
let ids = all_item_ids(&back);
let unique: HashSet<_> = ids.iter().collect();
assert_eq!(unique.len(), 3);
assert_eq!(back.stories[&sa].frames, vec![a]);
assert_eq!(back.stories[&sa].text, "first");
let frames = &back.stories[&sb].frames;
assert_eq!(frames.len(), 2);
assert_ne!(frames[0], a);
assert_eq!(frames[1], c);
assert_eq!(back.item(frames[0]).and_then(|i| i.text_frame()).map(|t| t.story), Some(sb));
assert_eq!(back.stories[&sb].text, "second");
}
#[test]
fn duplicate_anchored_item_ids_are_renumbered() {
use designcraft_doc::anchored::{AnchorPosition, AnchoredObject};
use designcraft_doc::geom::shapes;
use designcraft_doc::{Item, Shape};
let mut d = Document::new(&NewDocument::default());
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, "ab", ParaFormat::default()).unwrap();
let asset = png_asset(&mut d, &[1]);
let g = place(&mut d, asset, 0.0);
let item = Item::new(g, lid, Shape::Oval, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::default()));
let back = load(&save(&d).unwrap()).unwrap();
back.check().unwrap();
let ids = all_item_ids(&back);
assert_eq!(ids.len(), 3);
assert_eq!(ids.iter().collect::<HashSet<_>>().len(), 3);
assert_eq!(back.item(g).and_then(|i| i.graphic()).map(|g| g.asset), Some(asset));
let anchored = back.anchored_items();
assert_eq!(anchored.len(), 1);
assert_eq!(anchored[0].1.shape, Shape::Oval);
assert_ne!(anchored[0].1.id, g);
}
#[test]
fn duplicate_ids_at_the_top_of_the_range_do_not_overflow() {
let mut d = Document::new(&NewDocument::default());
let one = png_asset(&mut d, &[1]);
let two = png_asset(&mut d, &[2]);
place(&mut d, one, 0.0);
place(&mut d, two, 0.0);
set_item_id(&mut d, 0, designcraft_doc::ItemId(u64::MAX));
set_item_id(&mut d, 1, designcraft_doc::ItemId(u64::MAX));
d.renumber_duplicate_items();
assert_eq!(all_item_ids(&d), vec![u64::MAX, u64::MAX]);
assert!(d.check().is_err());
}