From d4bd716ebfe2138a4add49cc7e27b3c5bccbdc43 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:55 +0000 Subject: [PATCH] refactor(engine): move the text_clipboard_tests module of cmd/edit.rs to cmd/edit/text_clipboard_tests.rs --- crates/engine/src/cmd/edit.rs | 338 +----------------- .../src/cmd/edit/text_clipboard_tests.rs | 335 +++++++++++++++++ 2 files changed, 336 insertions(+), 337 deletions(-) create mode 100644 crates/engine/src/cmd/edit/text_clipboard_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a10e9ae2f..890974f32 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -550,343 +550,7 @@ fn graphic_alone(frame: &designcraft_doc::Item, g: &designcraft_doc::Graphic, pa } #[cfg(test)] -mod text_clipboard_tests { - use super::*; - - fn frame(s: &mut Session, text: &str) -> designcraft_doc::StoryId { - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); - s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story - } - - #[test] - fn paste_text_with_no_insertion_point_makes_a_text_frame() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); - // Text from another app, nothing copied here: a new, selected text frame. - let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap(); - let id = ItemId(r["id"].as_u64().unwrap()); - let d = s.doc().unwrap(); - assert_eq!(d.selection.items, vec![id]); - assert!(d.selection.text.is_none(), "selected as an object"); - let it = d.doc.item(id).unwrap(); - assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line"); - let page = &d.doc.spreads[0].pages[0]; - let (b, m) = (it.bounds(), page.margin_rect()); - assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}"); - assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}"); - assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}"); - assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(items(&s), 0, "one undo removes it"); - // Centred on `at`; hostile numbers are clamped. - let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap(); - let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); - assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}"); - assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err()); - s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap(); - // Copied objects paste when no text is given. - s.execute("edit.deselectAll", &json!({})).unwrap(); - let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); - s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - let n = items(&s); - let r = s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!(r["ids"].as_array().unwrap().len(), 1); - assert_eq!(items(&s), n + 1); - assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none()); - s.doc().unwrap().doc.check().unwrap(); - } - - /// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212). - #[test] - fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let story = frame(&mut s, "ab"); - let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone(); - s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - let marker = copied_objects_text(&s); - s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap(); - let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap(); - assert!(r.get("anchored").is_none(), "{r}"); - assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere"); - // What the object copy put on the system clipboard still pastes the objects. - assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1); - // With no insertion point too: the rectangle, not a text frame holding the marker. - s.execute("edit.deselectAll", &json!({})).unwrap(); - let r = s.execute("edit.paste", &json!({"text": marker})).unwrap(); - let id = ItemId(r["ids"][0].as_u64().unwrap()); - assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none()); - s.doc().unwrap().doc.check().unwrap(); - // Paste without Formatting inserts the text given; empty text is no text. - s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap(); - assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err()); - } - - /// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text. - #[test] - fn copied_text_frames_paste_at_a_caret_as_their_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let target = frame(&mut s, "ab"); - let copy_and_paste = |s: &mut Session, ids: Value, text: Value| { - s.execute("selection.set", &json!({"ids": ids})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("edit.paste", &json!({"text": text})).unwrap() - }; - let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone(); - // The same text copied in another app, and the system clipboard read back as absent. - let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1")); - assert!(r.get("anchored").is_none(), "{r}"); - s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab"); - // A group with a text frame inside, its own placeholder on the system clipboard. - let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone(); - let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone(); - let r = copy_and_paste(&mut s, json!([group]), Value::Null); - assert!(r.get("anchored").is_none(), "{r}"); - assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab"); - s.doc().unwrap().doc.check().unwrap(); - } - - #[test] - fn outside_text_never_pastes_markers() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - // Private-use characters from another app would read as footnote, table or variable markers. - let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}"; - let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap(); - let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}"); - s.doc().unwrap().doc.check().unwrap(); - let b = frame(&mut s, "Target"); - s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap(); - s.execute("edit.paste", &json!({"text": dirty})).unwrap(); - s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}"); - assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err()); - s.doc().unwrap().doc.check().unwrap(); - } - - #[test] - fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "Bold words\nSecond"); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap(); - s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap(); - let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); - s.execute("edit.deselectAll", &json!({})).unwrap(); - let r = s.execute("edit.paste", &json!({"text": copied})).unwrap(); - let d = s.doc().unwrap(); - let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap(); - assert_eq!(st.text, "Bold words\nSecond"); - assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold")); - assert_eq!(st.char_format_at(7).over.font_style, None); - assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]); - d.doc.check().unwrap(); - } - - #[test] - fn formatted_copy_paste_and_plain_paste() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "Bold words here."); - let b = frame(&mut s, "Target: ."); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - // A footnote inside the copied range travels with it. - s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 4})).unwrap(); - s.execute("footnote.insert", &json!({"text": "n"})).unwrap(); - let fl = designcraft_doc::FOOTNOTE_REF.len_utf8(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 10 + fl})).unwrap(); - let r = s.execute("edit.copy", &json!({})).unwrap(); - assert_eq!(r["text"], "Bold words"); - // Formatted paste when the system clipboard matches. - s.execute("text.select", &json!({"story": b.0, "anchor": 8, "focus": 8})).unwrap(); - s.execute("edit.paste", &json!({"text": "Bold words"})).unwrap(); - let st = s.doc().unwrap().doc.story(b).unwrap().clone(); - assert_eq!(st.text, format!("Target: Bold{} words.", designcraft_doc::FOOTNOTE_REF)); - assert_eq!(st.char_format_at(9).over.font_style.as_deref(), Some("Bold")); - assert_eq!(st.notes.len(), 1); - assert_eq!(st.notes[0].text.text, "n"); - // Other text on the system clipboard pastes as plain text. - s.execute("edit.paste", &json!({"text": "\"x\""})).unwrap(); - assert!(s.doc().unwrap().doc.story(b).unwrap().text.contains("\"x\""), "no smart quotes on paste"); - // Paste without formatting takes the insertion format. - s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); - let st = s.doc().unwrap().doc.story(b).unwrap().clone(); - assert!(st.text.starts_with("Bold wordsTarget"), "{:?}", st.text); - assert_eq!(st.char_format_at(1).over.font_style, None); - // Cut removes and copies. - s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 4})).unwrap(); - assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["text"], "Bold"); - assert!(s.doc().unwrap().doc.story(b).unwrap().text.starts_with(" wordsTarget")); - s.doc().unwrap().doc.check().unwrap(); - } - - fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let source = frame(&mut s, "aéz"); - s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); - s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); - s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); - s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); - s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); - assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); - (s, source) - } - - fn assert_child_note_paste( - s: &mut Session, - source: designcraft_doc::StoryId, - parent: designcraft_doc::StoryId, - cell: designcraft_doc::CellAddr, - endpoints: (usize, usize), - ) { - let before = s.doc().unwrap().doc.clone(); - let selected = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); - assert_eq!(selected.frame, None); - let before_child = before.text_story(parent, Some(cell)).unwrap(); - assert_eq!(before_child.text, "L----R"); - let clipboard = s.text_clipboard.clone(); - let history = s.execute("document.history", &json!({})).unwrap(); - assert_eq!(history["redo"], json!([])); - - let result = s.execute("edit.paste", &json!({})).unwrap(); - let after = s.doc().unwrap().doc.clone(); - let child = after.text_story(parent, Some(cell)).unwrap(); - assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); - assert_eq!(child.len(), 6); - assert_eq!(result, json!({"pos": 5, "formatted": true})); - assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); - assert!(child.endnotes.is_empty() && child.editorial.is_empty()); - for pos in [1, 2, 4] { - let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); - assert_eq!(format.over.font_style.as_deref(), Some("Bold")); - } - // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. - for pos in [0, 5] { - let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); - let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); - assert_eq!(new_format, old_format); - } - assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); - assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); - assert_eq!(after.story(source), before.story(source)); - assert_eq!(s.text_clipboard, clipboard); - - let mut pasted_history = history.clone(); - pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); - assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); - assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); - assert_eq!(s.doc().unwrap().doc, before); - let mut undone_history = history; - undone_history["redo"] = json!(["Paste"]); - assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); - // This filter does not change which selection Undo restores. - assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); - assert_eq!(s.doc().unwrap().doc, after); - assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); - } - - #[test] - fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { - let (mut s, source) = note_clipboard_session(); - let parent = frame(&mut s, "PZ"); - s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); - let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); - s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); - s.execute("table.nextCell", &json!({})).unwrap(); - s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); - s.execute("table.prevCell", &json!({})).unwrap(); - s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); - s.execute("text.move", &json!({"dir": "right"})).unwrap(); - for _ in 0..4 { - s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); - } - let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); - let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; - assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); - let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; - assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); - let mut expected_table = (*before_table).clone(); - expected_table.cells[0].text = table.cells[0].text.clone(); - assert_eq!(table.as_ref(), &expected_table); - - // The same clipboard must still retain both markers when pasted into an ordinary root story. - let root = frame(&mut s, "L----R"); - s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); - assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); - assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); - } - - #[test] - fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { - let (mut s, source) = note_clipboard_session(); - let parent = frame(&mut s, "PZ"); - let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); - let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.move", &json!({"dir": "left"})).unwrap(); - for _ in 0..4 { - s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); - } - let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); - assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); - let after = s.doc().unwrap().doc.story(parent).unwrap(); - assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); - assert_eq!(after.note(neighbor), before.note(neighbor)); - assert_eq!(after.notes.iter().map(|note| note.id).collect::>(), before.notes.iter().map(|note| note.id).collect::>()); - } - - #[test] - fn change_case_keeps_runs() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "the quick brown fox. jumps over straße"); - s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 9})).unwrap(); - s.execute("type.italic", &json!({})).unwrap(); - let len = s.doc().unwrap().doc.story(a).unwrap().len(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); - s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The Quick Brown Fox. Jumps Over Straße"); - s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The quick brown fox. Jumps over straße"); - s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); - let st = s.doc().unwrap().doc.story(a).unwrap().clone(); - assert_eq!(st.text, "THE QUICK BROWN FOX. JUMPS OVER STRASSE"); - assert_eq!(st.char_format_at(5).over.font_style.as_deref(), Some("Italic")); - assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len()); - st.check().unwrap(); - } - - #[test] - fn change_case_leaves_georgian_without_initial_capitals() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word"); - let len = s.doc().unwrap().doc.story(a).unwrap().len(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); - // Georgian has no title or sentence capitals: Mtavruli is only for all-caps text. - s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word"); - s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word"); - s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD"); - } -} +mod text_clipboard_tests; fn paste_into(s: &mut Session, p: &Value) -> Result { use super::object::RemoveKeep; diff --git a/crates/engine/src/cmd/edit/text_clipboard_tests.rs b/crates/engine/src/cmd/edit/text_clipboard_tests.rs new file mode 100644 index 000000000..d947105cd --- /dev/null +++ b/crates/engine/src/cmd/edit/text_clipboard_tests.rs @@ -0,0 +1,335 @@ +use super::*; + +fn frame(s: &mut Session, text: &str) -> designcraft_doc::StoryId { + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); + s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story +} + +#[test] +fn paste_text_with_no_insertion_point_makes_a_text_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + // Text from another app, nothing copied here: a new, selected text frame. + let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let d = s.doc().unwrap(); + assert_eq!(d.selection.items, vec![id]); + assert!(d.selection.text.is_none(), "selected as an object"); + let it = d.doc.item(id).unwrap(); + assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line"); + let page = &d.doc.spreads[0].pages[0]; + let (b, m) = (it.bounds(), page.margin_rect()); + assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}"); + assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}"); + assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}"); + assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(items(&s), 0, "one undo removes it"); + // Centred on `at`; hostile numbers are clamped. + let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap(); + let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); + assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}"); + assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err()); + s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap(); + // Copied objects paste when no text is given. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let n = items(&s); + let r = s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + assert_eq!(items(&s), n + 1); + assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); +} + +/// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212). +#[test] +fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let story = frame(&mut s, "ab"); + let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let marker = copied_objects_text(&s); + s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap(); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere"); + // What the object copy put on the system clipboard still pastes the objects. + assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1); + // With no insertion point too: the rectangle, not a text frame holding the marker. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": marker})).unwrap(); + let id = ItemId(r["ids"][0].as_u64().unwrap()); + assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); + // Paste without Formatting inserts the text given; empty text is no text. + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err()); +} + +/// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text. +#[test] +fn copied_text_frames_paste_at_a_caret_as_their_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let target = frame(&mut s, "ab"); + let copy_and_paste = |s: &mut Session, ids: Value, text: Value| { + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("edit.paste", &json!({"text": text})).unwrap() + }; + let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone(); + // The same text copied in another app, and the system clipboard read back as absent. + let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1")); + assert!(r.get("anchored").is_none(), "{r}"); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab"); + // A group with a text frame inside, its own placeholder on the system clipboard. + let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone(); + let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone(); + let r = copy_and_paste(&mut s, json!([group]), Value::Null); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab"); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn outside_text_never_pastes_markers() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Private-use characters from another app would read as footnote, table or variable markers. + let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}"; + let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}"); + s.doc().unwrap().doc.check().unwrap(); + let b = frame(&mut s, "Target"); + s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap(); + s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}"); + assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err()); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "Bold words\nSecond"); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap(); + s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap(); + let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": copied})).unwrap(); + let d = s.doc().unwrap(); + let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.text, "Bold words\nSecond"); + assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold")); + assert_eq!(st.char_format_at(7).over.font_style, None); + assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]); + d.doc.check().unwrap(); +} + +#[test] +fn formatted_copy_paste_and_plain_paste() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "Bold words here."); + let b = frame(&mut s, "Target: ."); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + // A footnote inside the copied range travels with it. + s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 4})).unwrap(); + s.execute("footnote.insert", &json!({"text": "n"})).unwrap(); + let fl = designcraft_doc::FOOTNOTE_REF.len_utf8(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 10 + fl})).unwrap(); + let r = s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(r["text"], "Bold words"); + // Formatted paste when the system clipboard matches. + s.execute("text.select", &json!({"story": b.0, "anchor": 8, "focus": 8})).unwrap(); + s.execute("edit.paste", &json!({"text": "Bold words"})).unwrap(); + let st = s.doc().unwrap().doc.story(b).unwrap().clone(); + assert_eq!(st.text, format!("Target: Bold{} words.", designcraft_doc::FOOTNOTE_REF)); + assert_eq!(st.char_format_at(9).over.font_style.as_deref(), Some("Bold")); + assert_eq!(st.notes.len(), 1); + assert_eq!(st.notes[0].text.text, "n"); + // Other text on the system clipboard pastes as plain text. + s.execute("edit.paste", &json!({"text": "\"x\""})).unwrap(); + assert!(s.doc().unwrap().doc.story(b).unwrap().text.contains("\"x\""), "no smart quotes on paste"); + // Paste without formatting takes the insertion format. + s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); + let st = s.doc().unwrap().doc.story(b).unwrap().clone(); + assert!(st.text.starts_with("Bold wordsTarget"), "{:?}", st.text); + assert_eq!(st.char_format_at(1).over.font_style, None); + // Cut removes and copies. + s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 4})).unwrap(); + assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["text"], "Bold"); + assert!(s.doc().unwrap().doc.story(b).unwrap().text.starts_with(" wordsTarget")); + s.doc().unwrap().doc.check().unwrap(); +} + +fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let source = frame(&mut s, "aéz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); + s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); + s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); + (s, source) +} + +fn assert_child_note_paste( + s: &mut Session, + source: designcraft_doc::StoryId, + parent: designcraft_doc::StoryId, + cell: designcraft_doc::CellAddr, + endpoints: (usize, usize), +) { + let before = s.doc().unwrap().doc.clone(); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); + assert_eq!(selected.frame, None); + let before_child = before.text_story(parent, Some(cell)).unwrap(); + assert_eq!(before_child.text, "L----R"); + let clipboard = s.text_clipboard.clone(); + let history = s.execute("document.history", &json!({})).unwrap(); + assert_eq!(history["redo"], json!([])); + + let result = s.execute("edit.paste", &json!({})).unwrap(); + let after = s.doc().unwrap().doc.clone(); + let child = after.text_story(parent, Some(cell)).unwrap(); + assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); + assert_eq!(child.len(), 6); + assert_eq!(result, json!({"pos": 5, "formatted": true})); + assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); + assert!(child.endnotes.is_empty() && child.editorial.is_empty()); + for pos in [1, 2, 4] { + let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(format.over.font_style.as_deref(), Some("Bold")); + } + // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. + for pos in [0, 5] { + let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(new_format, old_format); + } + assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); + assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); + assert_eq!(after.story(source), before.story(source)); + assert_eq!(s.text_clipboard, clipboard); + + let mut pasted_history = history.clone(); + pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, before); + let mut undone_history = history; + undone_history["redo"] = json!(["Paste"]); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); + // This filter does not change which selection Undo restores. + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, after); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); +} + +#[test] +fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); + s.execute("table.prevCell", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); + let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; + assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); + let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; + assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); + let mut expected_table = (*before_table).clone(); + expected_table.cells[0].text = table.cells[0].text.clone(); + assert_eq!(table.as_ref(), &expected_table); + + // The same clipboard must still retain both markers when pasted into an ordinary root story. + let root = frame(&mut s, "L----R"); + s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); + assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); + assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); +} + +#[test] +fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + } + let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); + assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); + let after = s.doc().unwrap().doc.story(parent).unwrap(); + assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); + assert_eq!(after.note(neighbor), before.note(neighbor)); + assert_eq!(after.notes.iter().map(|note| note.id).collect::>(), before.notes.iter().map(|note| note.id).collect::>()); +} + +#[test] +fn change_case_keeps_runs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "the quick brown fox. jumps over straße"); + s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 9})).unwrap(); + s.execute("type.italic", &json!({})).unwrap(); + let len = s.doc().unwrap().doc.story(a).unwrap().len(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); + s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The Quick Brown Fox. Jumps Over Straße"); + s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The quick brown fox. Jumps over straße"); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + let st = s.doc().unwrap().doc.story(a).unwrap().clone(); + assert_eq!(st.text, "THE QUICK BROWN FOX. JUMPS OVER STRASSE"); + assert_eq!(st.char_format_at(5).over.font_style.as_deref(), Some("Italic")); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len()); + st.check().unwrap(); +} + +#[test] +fn change_case_leaves_georgian_without_initial_capitals() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word"); + let len = s.doc().unwrap().doc.story(a).unwrap().len(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); + // Georgian has no title or sentence capitals: Mtavruli is only for all-caps text. + s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word"); + s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word"); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD"); +}