use designcraft_geom::Rect; use super::*; use crate::build::NewDocument; fn doc() -> Document { Document::new(&NewDocument { pages: 2, ..Default::default() }) } #[test] fn threading_moves_frames_and_text() { let mut d = doc(); let lid = d.default_layer(); let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap(); let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap(); d.thread(a, b).unwrap(); d.check().unwrap(); assert!(d.story(sb).is_none()); let st = d.story(sa).unwrap(); assert_eq!(st.text, "first\nsecond"); assert_eq!(st.frames, vec![a, b]); assert_eq!(d.next_frame(a), Some(b)); assert_eq!(d.prev_frame(b), Some(a)); d.unthread_after(a).unwrap(); d.check().unwrap(); assert_eq!(d.story(sa).unwrap().frames, vec![a]); assert_ne!(d.item(b).unwrap().text_frame().unwrap().story, sa); } #[test] fn thread_to_empty_frame_converts_it() { let mut d = doc(); let lid = d.default_layer(); let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "x", ParaFormat::default()).unwrap(); let id = ItemId(d.alloc()); let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0))); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); d.thread(a, id).unwrap(); assert!(d.item(id).unwrap().is_text_frame()); assert_eq!(d.story(sa).unwrap().frames, vec![a, id]); d.check().unwrap(); assert!(d.thread(a, a).is_err()); } #[test] fn threading_merges_tables_notes_and_endnotes() { let mut d = doc(); let lid = d.default_layer(); let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap(); let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap(); for (sid, note) in [(sa, "Note A"), (sb, "Note B")] { let st = d.story_mut(sid).unwrap(); st.insert_note(3, note, ParaFormat::default()); // Both stories hold a table with id 1. let n = st.len(); st.insert_table(n, crate::table::Table::new(1, 2, 2, 0, 0, 80.0)); } let st = d.story_mut(sb).unwrap(); st.insert_endnote(2, "End B", ParaFormat::default()); st.insert(1, &crate::NOTE_MARK.to_string()); d.thread(a, b).unwrap(); d.check().unwrap(); let st = d.story(sa).unwrap(); assert!(d.story(sb).is_none()); assert_eq!(st.tables.len(), 2); assert_eq!(st.notes.len(), 2); assert_eq!(st.notes[1].text.text, "Note B"); assert_eq!(st.endnotes.len(), 1); assert_eq!(st.endnotes[0].text.text, "End B"); assert_eq!(st.editorial.len(), 1); } #[test] fn empty_frame_threaded_before_a_story_joins_it_at_the_front() { let mut d = doc(); let lid = d.default_layer(); let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap(); let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); d.thread_keeping_story(a, b).unwrap(); d.check().unwrap(); assert!(d.story(sa).is_none()); let st = d.story(sb).unwrap(); assert_eq!(st.frames, vec![a, b]); assert_eq!(st.text, "Hello"); } #[test] fn empty_story_takes_the_threaded_text_without_a_blank_paragraph() { let mut d = doc(); let lid = d.default_layer(); let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap(); let (a2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 110.0, 100.0, 200.0), lid, "", ParaFormat::default()).unwrap(); d.thread(a, a2).unwrap(); let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); d.thread(a2, b).unwrap(); d.check().unwrap(); let st = d.story(d.item(a).unwrap().text_frame().unwrap().story).unwrap(); assert_eq!(st.frames, vec![a, a2, b]); assert_eq!(st.text, "Hello"); } #[test] fn threading_from_an_empty_frame_makes_it_a_text_frame() { let mut d = doc(); let lid = d.default_layer(); let id = ItemId(d.alloc()); let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0))); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); d.thread(id, b).unwrap(); d.check().unwrap(); let st = d.story(d.item(id).unwrap().text_frame().unwrap().story).unwrap(); assert_eq!(st.frames, vec![id, b]); assert_eq!(st.text, "Hello"); } #[test] fn removing_a_frame_from_its_thread_keeps_the_text_in_the_others() { let mut d = doc(); let lid = d.default_layer(); let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "words", ParaFormat::default()).unwrap(); let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 10.0, 300.0, 100.0), lid, "", ParaFormat::default()).unwrap(); d.thread(a, b).unwrap(); d.thread(b, c).unwrap(); d.remove_from_thread(b).unwrap(); d.check().unwrap(); assert_eq!(d.story(sa).unwrap().frames, vec![a, c]); assert_eq!(d.story(sa).unwrap().text, "words"); let own = d.item(b).unwrap().text_frame().unwrap().story; assert_ne!(own, sa); assert!(d.story(own).unwrap().text.is_empty()); // A frame alone in its story has no thread to leave. assert!(d.remove_from_thread(b).is_err()); } #[test] fn a_frame_removed_from_a_vertical_thread_stays_vertical() { let mut d = doc(); let lid = d.default_layer(); let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "縦書き", ParaFormat::default()).unwrap(); let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); d.story_mut(sa).unwrap().vertical = true; d.thread(a, b).unwrap(); d.remove_from_thread(b).unwrap(); d.check().unwrap(); assert!(d.frame_vertical(d.item(a).unwrap())); assert!(d.frame_vertical(d.item(b).unwrap()), "the removed frame keeps the story's direction"); } #[test] fn a_frame_removed_from_a_right_to_left_thread_keeps_its_column_order() { let mut d = doc(); let lid = d.default_layer(); let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "نص", ParaFormat::default()).unwrap(); let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); d.story_mut(sa).unwrap().direction = crate::TextDirection::RightToLeft; d.thread(a, b).unwrap(); d.remove_from_thread(b).unwrap(); d.check().unwrap(); let own = d.item(b).unwrap().text_frame().unwrap().story; assert_eq!(d.story(own).unwrap().direction, crate::TextDirection::RightToLeft); } #[test] fn frames_keep_their_story_direction_through_threading() { let mut d = doc(); let lid = d.default_layer(); let r = |x: f64| Rect::new(x, 10.0, x + 50.0, 100.0); let vertical = |d: &Document, f: ItemId| d.item(f).is_some_and(|i| d.frame_vertical(i)); let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), r(0.0), lid, "縦", ParaFormat::default()).unwrap(); let (b, _) = d.add_text_frame(SpreadRef::Doc(0), r(60.0), lid, "", ParaFormat::default()).unwrap(); d.story_mut(sa).unwrap().vertical = true; // A horizontal frame threaded onto a vertical story turns vertical. d.thread(a, b).unwrap(); assert!(vertical(&d, a) && vertical(&d, b)); // Threading the vertical story's second frame after a horizontal frame: it turns horizontal, // and the frame left before it stays vertical. let (h, _) = d.add_text_frame(SpreadRef::Doc(0), r(120.0), lid, "横", ParaFormat::default()).unwrap(); d.thread(h, b).unwrap(); assert!(vertical(&d, a) && !vertical(&d, b) && !vertical(&d, h)); // Breaking a vertical thread leaves the frames after the break vertical. let (c, _) = d.add_text_frame(SpreadRef::Doc(0), r(180.0), lid, "", ParaFormat::default()).unwrap(); d.thread(a, c).unwrap(); d.unthread_after(a).unwrap(); assert!(vertical(&d, a) && vertical(&d, c)); assert_ne!(d.item(c).unwrap().text_frame().unwrap().story, sa); d.check().unwrap(); } #[test] fn frames_keep_their_column_direction_through_threading() { use crate::TextDirection::{LeftToRight, RightToLeft}; let mut d = doc(); let lid = d.default_layer(); let r = |x: f64| Rect::new(x, 10.0, x + 50.0, 100.0); let direction = |d: &Document, f: ItemId| d.story(d.item(f).unwrap().text_frame().unwrap().story).unwrap().direction; let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), r(0.0), lid, "نص", ParaFormat::default()).unwrap(); let (b, _) = d.add_text_frame(SpreadRef::Doc(0), r(60.0), lid, "", ParaFormat::default()).unwrap(); d.story_mut(sa).unwrap().direction = RightToLeft; d.thread(a, b).unwrap(); assert_eq!((direction(&d, a), direction(&d, b)), (RightToLeft, RightToLeft)); // Threading the right-to-left story's second frame after a left-to-right frame: the frame // left before it stays right to left. let (h, _) = d.add_text_frame(SpreadRef::Doc(0), r(120.0), lid, "abc", ParaFormat::default()).unwrap(); d.thread(h, b).unwrap(); assert_eq!((direction(&d, a), direction(&d, b), direction(&d, h)), (RightToLeft, LeftToRight, LeftToRight)); // Breaking a right-to-left thread leaves the frames after the break right to left. let (c, _) = d.add_text_frame(SpreadRef::Doc(0), r(180.0), lid, "", ParaFormat::default()).unwrap(); d.thread(a, c).unwrap(); d.unthread_after(a).unwrap(); assert_ne!(d.item(c).unwrap().text_frame().unwrap().story, sa); assert_eq!((direction(&d, a), direction(&d, c)), (RightToLeft, RightToLeft)); d.check().unwrap(); } #[test] fn removing_last_frame_deletes_story() { let mut d = doc(); let lid = d.default_layer(); let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "x", ParaFormat::default()).unwrap(); d.remove_item(a).unwrap(); assert!(d.story(sa).is_none()); d.check().unwrap(); } #[test] fn hit_testing_respects_layers() { let mut d = doc(); let lid = d.default_layer(); let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "x", ParaFormat::default()).unwrap(); assert_eq!(d.hit_item(0, designcraft_geom::Point::new(50.0, 50.0), 2.0), Some(a)); assert_eq!(d.hit_item(0, designcraft_geom::Point::new(300.0, 300.0), 2.0), None); d.layer_mut(lid).unwrap().locked = true; assert_eq!(d.hit_item(0, designcraft_geom::Point::new(50.0, 50.0), 2.0), None); } #[test] fn page_numbers_saturate_at_the_largest_start() { // A start number from a file or an old command: page 2 overflowed (a panic in debug builds). let mut d = crate::Document::new(&crate::build::NewDocument { pages: 3, facing_pages: false, ..Default::default() }); d.sections = vec![crate::Section { start: 0, start_number: Some(u32::MAX), style: Default::default(), prefix: String::new(), marker: String::new(), include_prefix: false, }]; assert_eq!((0..3).map(|i| d.page_number(i)).collect::>(), [u32::MAX; 3]); d.sections.push(crate::Section { start: 2, start_number: None, ..d.sections[0].clone() }); assert_eq!(d.page_number(2), u32::MAX, "a section continuing it saturates too"); } #[test] fn page_names_follow_sections() { let mut d = Document::new(&NewDocument { pages: 6, ..Default::default() }); assert_eq!(d.page_name(0), "1"); d.sections.push(Section { start: 2, start_number: Some(1), style: NumberStyle::LowerRoman, prefix: "A-".into(), marker: String::new(), include_prefix: true, }); assert_eq!(d.page_name(1), "2"); assert_eq!(d.page_name(2), "A-i"); assert_eq!(d.page_name(4), "A-iii"); // A section that continues numbering. d.sections.push(Section { start: 5, start_number: None, style: NumberStyle::Arabic, prefix: String::new(), marker: String::new(), include_prefix: false, }); assert_eq!(d.page_name(5), "4"); } /// A section restarting at 1 on page 2 leaves two odd (right) pages in a row; each gets its own /// spread instead of sharing one with the odd page on the left. #[test] fn consecutive_right_pages_get_separate_spreads() { let mut d = Document::new(&NewDocument { pages: 4, ..Default::default() }); d.sections.push(Section { start: 1, start_number: Some(1), style: NumberStyle::Arabic, prefix: String::new(), marker: String::new(), include_prefix: false, }); d.repaginate(); let sides: Vec> = d.spreads.iter().map(|s| s.pages.iter().map(|p| p.side).collect()).collect(); assert_eq!(sides, vec![vec![PageSide::Right], vec![PageSide::Right], vec![PageSide::Left, PageSide::Right]]); let flat: Vec = sides.into_iter().flatten().collect(); for (i, side) in flat.iter().enumerate() { assert_eq!(*side == PageSide::Right, d.page_number(i) % 2 == 1, "page {i}"); } d.check().unwrap(); } #[test] fn hostile_section_start_numbers_saturate() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); let first = d.sections.iter_mut().find(|s| s.start == 0).unwrap(); first.start_number = Some(u32::MAX); let continuing = Section { start: 2, start_number: None, ..first.clone() }; d.sections.push(continuing); assert_eq!(d.page_number(1), u32::MAX); assert_eq!(d.page_number(2), u32::MAX); assert_eq!(d.page_name(2), u32::MAX.to_string()); } #[test] fn document_serde_roundtrip() { let mut d = doc(); let lid = d.default_layer(); d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "héllo\nworld", ParaFormat::default()).unwrap(); let s = serde_json::to_string(&d).unwrap(); let back: Document = serde_json::from_str(&s).unwrap(); assert_eq!(d, back); back.check().unwrap(); } /// A file can set `next_id` to `u64::MAX`; the next allocated id then overflowed (IDML export /// adds to it too). Such a document is rejected when it is checked on load. #[test] fn next_id_near_the_top_is_rejected() { let mut d = Document::new(&crate::build::NewDocument::default()); d.check().unwrap(); d.next_id = u64::MAX; assert!(d.check().is_err()); d.next_id = crate::MAX_NEXT_ID; d.check().unwrap(); } /// A placed PDF saved before its crop choice was stored loads with the crop box (the whole page), /// and that default isn't written out. #[test] fn placed_pdf_without_a_crop_choice_loads_with_the_crop_box() { let a: Asset = serde_json::from_str(r#"{"id": 7, "name": "a.pdf", "mime": "application/pdf", "page": 1}"#).unwrap(); assert_eq!((a.pdf_crop, a.pdf_box, a.shown_box()), (PdfCrop::Crop, None, None)); let json = serde_json::to_string(&a).unwrap(); assert!(!json.contains("pdfCrop") && !json.contains("pdfBox"), "{json}"); let trim = Asset { pdf_crop: PdfCrop::Trim, pdf_box: Some([0.1, 0.1, 0.9, 0.9]), ..a }; let back: Asset = serde_json::from_str(&serde_json::to_string(&trim).unwrap()).unwrap(); assert_eq!((back.pdf_crop, back.shown_box()), (PdfCrop::Trim, Some([0.1, 0.1, 0.9, 0.9]))); // A box with no area shows the whole page. assert_eq!(Asset { pdf_box: Some([0.5, 0.5, 0.5, 0.9]), ..back }.shown_box(), None); }