// Reuse our hand-written Czech proof fixture; its install helper resolves this parent import. use wordcraft_proof::cs::install_dictionary; #[path = "../../proof/src/cs/test_dict.rs"] mod czech_test_dict; use serde_json::json; use wordcraft_doc::props::BorderStyle; use wordcraft_doc::{Pos, StoryRef}; use crate::{Session, cmd}; fn s() -> Session { Session::new(wordcraft_doc::Document::new()) } fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value { s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) } fn text(s: &Session) -> String { s.doc.plain_text(StoryRef::Body) } #[test] fn doc_extension_dispatches_to_docbin() { // Garbage bytes with a .doc name must produce an error through the Word 97-2003 // reader — never a panic and never a silently empty document. for name in ["x.doc", "x.dot"] { assert!(crate::io::open_bytes(name, &[0u8; 64]).is_err(), "{name} should fail"); } } #[test] fn encrypted_doc_asks_for_a_password_and_rejects_a_wrong_one() { // #1079: a Word 97-2003 file with the encryption flag and an RC4 header whose verifier does // not match any password asks for one, and a given password is reported as wrong. use std::io::Write; let mut fib = vec![0u8; 0x100]; fib[0..2].copy_from_slice(&0xA5ECu16.to_le_bytes()); fib[0x0A..0x0C].copy_from_slice(&0x0100u16.to_le_bytes()); let mut header = vec![1u8, 0, 1, 0]; header.extend_from_slice(&[0x5A; 48]); let mut comp = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap(); for (name, data) in [("WordDocument", &fib), ("0Table", &header)] { comp.create_stream(format!("/{name}")).unwrap().write_all(data).unwrap(); } let bytes = comp.into_inner().into_inner(); for name in ["x.doc", "x.dot"] { let e = crate::io::open_bytes_with(name, &bytes, None).unwrap_err(); assert!(crate::io::needs_password(&e), "{e}"); let e = crate::io::open_bytes_with(name, &bytes, Some("nope")).unwrap_err(); assert!(crate::io::wrong_password(&e), "{e}"); } // Opened by content (no extension) it asks the same way. let e = crate::io::open_bytes_with("noext", &bytes, None).unwrap_err(); assert!(crate::io::needs_password(&e), "{e}"); } #[test] fn txt_open_decodes_windows_1252_and_unmarked_utf16() { // #433: the TXT open path sniffs encodings like the formats importer. let open = |b: &[u8]| crate::io::open_bytes("x.txt", b).unwrap().plain_text(StoryRef::Body); assert_eq!(open(b"\x80"), "€"); assert_eq!(open(b"A\0B\0"), "AB"); assert_eq!(open(b"\0A\0B"), "AB"); assert_eq!(open("€".as_bytes()), "€"); assert_eq!(open(b"\xFF\xFEA\0B\0"), "AB"); assert_eq!(open(b"A\0B"), "AB", "odd byte count stays safe"); assert_eq!(crate::io::decode_text(b"caf\xE9 \x80"), "café €"); } #[test] fn txt_save_writes_list_labels() { // #434: list paragraphs keep their bullet or number and a tab in plain text. let doc = crate::io::open_bytes("l.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap(); assert_eq!(crate::io::save_bytes("l.txt", &doc).unwrap(), "•\talpha\r\n•\tbeta\r\n1.\tfirst\r\n2.\tsecond".as_bytes()); let plain = crate::io::open_bytes("p.md", b"alpha\n\nbeta\n").unwrap(); assert_eq!(crate::io::save_bytes("p.txt", &plain).unwrap(), b"alpha\r\nbeta"); } #[test] fn every_command_has_unique_id() { let reg = cmd::registry(); let mut ids: Vec<&str> = reg.all().iter().map(|c| c.id).collect(); let n = ids.len(); ids.sort(); ids.dedup(); assert_eq!(ids.len(), n, "duplicate command ids"); assert!(n > 150, "{n} commands"); } #[test] fn every_shortcut_runs_one_command() { // `by_shortcut` takes the first match, so a shared key silently shadows the later command (#186). let reg = cmd::registry(); let mut seen = std::collections::HashMap::new(); for c in reg.all() { for k in c.shortcut.split(" / ").filter(|k| !k.is_empty()) { if let Some(other) = seen.insert(k.to_ascii_lowercase(), c.id) { panic!("{k} is bound to both {other} and {}", c.id); } } } assert_eq!(reg.by_shortcut("Shift+F3").map(|c| c.id), Some("format.changeCase")); } #[test] fn word_default_shortcuts_resolve() { // #330: Word's documented default shortcuts reach the matching commands, with Word for // Mac's keys where macOS takes Word's (⌘Q quits, ⌘Space is Spotlight, ⌘⇧G is Find Previous). let reg = cmd::registry(); let id = |k: &str| reg.by_shortcut(k).map(|c| c.id); assert_eq!(id("Mod+Alt+1"), Some("para.heading1")); assert_eq!(id("Mod+Alt+P"), Some("view.printLayout")); assert_eq!(id("Mod+Alt+O"), Some("view.outline")); assert_eq!(id("Mod+Alt+N"), Some("view.draft")); assert_eq!(id("Mod+Alt+S"), Some("view.split")); assert_eq!(id("Mod+Alt+-"), Some("text.emDash")); assert_eq!(id("Mod+Shift+F5"), Some("insert.bookmark")); assert_eq!(id("Alt+F7"), Some("review.spelling")); assert_eq!(id("Shift+F12"), Some("file.save")); assert_eq!(id(cmd::mac_or("Ctrl+Q", "Mod+Q")), Some("para.reset")); assert_eq!(id(cmd::mac_or("Ctrl+Space", "Mod+Space")), Some("format.resetChar")); assert_eq!(id(cmd::mac_or("Ctrl+Shift+D", "Alt+Shift+D")), Some("insert.dateField")); // Option+arrows move by word everywhere; ⌘←/⌘→ go to the line's ends on macOS (#954). assert_eq!(id("Alt+Left"), Some("caret.wordLeft")); assert_eq!(id("Alt+Right"), Some("caret.wordRight")); assert_eq!(id("Mod+Left"), Some(cmd::mac_or("caret.home", "caret.wordLeft"))); assert_eq!(id("Mod+Right"), Some(cmd::mac_or("caret.end", "caret.wordRight"))); assert_eq!(id("Shift+F1"), Some("styles.inspector")); assert_eq!(id("Alt+F8"), Some("tools.macros")); assert_eq!(id("Mod+Shift+X"), Some("format.strikethrough")); assert_eq!(id("Shift+F4"), Some("edit.findNext")); assert_eq!(id("Mod+Insert"), Some("edit.copy")); assert_eq!(id("Shift+Insert"), Some("edit.paste")); assert_eq!(id("Mod+Alt+Z"), Some("edit.goBack")); assert_eq!(id("Mod+Alt+F2"), Some("file.open")); assert_eq!(id("Alt+Shift+F2"), Some("file.save")); assert_eq!(id("Mod+Alt+I"), Some("file.print")); assert_eq!(id("Alt+Shift+F1"), Some("fields.previous")); if cfg!(target_os = "macos") { assert_eq!(id("Mod+Shift+G"), Some("edit.findPrevious")); // Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E endnote (⌘⌥D hides the Dock), ⌘⌥A comment, ⌃H Replace. assert_eq!(id("Mod+Shift+S"), Some("file.saveAs")); assert_eq!(id("Mod+Alt+E"), Some("references.endnote")); assert_eq!(id("Mod+Alt+D"), None); assert_eq!(id("Mod+Alt+A"), Some("review.newComment")); assert_eq!(id("Ctrl+H"), Some("edit.findReplace")); assert_eq!(id("Mod+Alt+L"), Some("review.spelling")); } else { assert_eq!(id("Mod+Shift+S"), Some("styles.apply")); assert_eq!(id("Mod+Alt+D"), Some("references.endnote")); assert_eq!(id("Mod+Alt+A"), None); assert_eq!(id("Mod+Shift+G"), Some("review.wordCount")); assert_eq!(id("Mod+Alt+C"), Some("text.copyright")); assert_eq!(id("Mod+Alt+H"), Some("format.highlight")); assert_eq!(id("Alt+Shift+L"), Some("outline.firstLineOnly")); assert_eq!(id("Alt+Shift+>"), Some("references.nextFootnote")); assert_eq!(id("Alt+Shift+."), Some("references.nextFootnote")); assert_eq!(id("Alt+Shift+R"), Some("layout.linkToPrevious")); assert_eq!(id("Alt+F3"), Some("insert.autoText")); assert_eq!(id("Alt+F2"), Some("file.saveAs")); assert_eq!(id("Alt+F1"), Some("fields.next")); } assert_eq!(id("Mod+PageUp"), Some("edit.findPrevious")); // The Spike: Word's Ctrl+F3 and Ctrl+Shift+F3 (⌘F3 and ⌘⇧F3 in Word for Mac). assert_eq!(id("Mod+F3"), Some("edit.spike")); assert_eq!(id("Mod+Shift+F3"), Some("edit.insertSpike")); assert_eq!(id("F3"), Some("edit.findNext")); assert_eq!(id("Shift+F3"), Some("format.changeCase")); assert_eq!(id("F12"), Some("file.saveAs")); assert_eq!(id("Mod+Alt+M"), Some("review.newComment")); assert_eq!(id("Mod+H"), Some("edit.findReplace")); } #[test] fn reset_shortcuts_remove_direct_formatting_but_keep_styles() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Hello world"})); run(&mut s, "para.heading1", json!({})); run(&mut s, "para.indent", json!({})); run(&mut s, "para.alignCenter", json!({})); run(&mut s, "para.reset", json!({})); let props = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.clone(); assert_eq!(props.style.as_deref(), Some("Heading1"), "Ctrl+Q keeps the paragraph style"); assert_eq!((props.indent_left, props.align), (None, None), "Ctrl+Q drops direct paragraph formatting"); run(&mut s, "select.all", json!({})); run(&mut s, "format.bold", json!({})); run(&mut s, "format.resetChar", json!({})); let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); assert_eq!(p.props_of_char(1).bold, None, "Ctrl+Space drops direct character formatting"); assert_eq!(p.props.style.as_deref(), Some("Heading1"), "Ctrl+Space leaves the paragraph alone"); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "text.emDash", json!({})); assert_eq!(text(&s), "Hello world—"); } #[test] fn custom_dictionary_words_persist_through_their_file() { // #40: adding a word through a command writes it to the session's file, and loading // that file makes the word known (only the desktop app wires this up). let dir = std::env::temp_dir().join(format!("wc-engdict-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); let path = dir.join("custom-dictionary.txt"); let _ = std::fs::remove_file(&path); let mut s = s(); s.user_dict_path = Some(path.clone()); run(&mut s, "review.addToDictionary", json!({"word": "zxqengpersist"})); let saved = std::fs::read_to_string(&path).unwrap_or_default(); assert!(saved.lines().any(|l| l == "zxqengpersist"), "the word reaches the file: {saved:?}"); run(&mut s, "file.options", json!({"removeWords": ["zxqengpersist"]})); let saved = std::fs::read_to_string(&path).unwrap_or_default(); assert!(!saved.lines().any(|l| l == "zxqengpersist"), "removal reaches the file too: {saved:?}"); // Ignore All is for this session only, as in Word: the word is accepted, never saved. assert!(!wordcraft_proof::is_correct("zxqengignored")); run(&mut s, "review.ignoreAll", json!({"word": "zxqengignored"})); assert!(wordcraft_proof::is_correct("zxqengignored")); assert!(!wordcraft_proof::user_dictionary().contains(&"zxqengignored".to_string())); let saved = std::fs::read_to_string(&path).unwrap_or_default(); assert!(!saved.contains("zxqengignored"), "Ignore All doesn't reach the file: {saved:?}"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn add_to_dictionary_takes_only_words_the_file_keeps() { // #40: the same rule as loading the dictionary file, so nothing added is dropped on the next load. let mut s = s(); for bad in ["two words", "tab\tword", "bell\u{7}", &"x".repeat(65)] { assert!(s.run("review.addToDictionary", &json!({"word": bad})).is_err(), "{bad:?}"); assert!(!wordcraft_proof::user_dictionary().iter().any(|w| w == &bad.to_lowercase())); } run(&mut s, "review.addToDictionary", json!({"word": " zxqengtrimmed "})); assert!(wordcraft_proof::user_dictionary().contains(&"zxqengtrimmed".to_string())); } #[test] fn non_english_runs_are_not_proofed() { // #100: runs tagged with a proofing language that has no dictionary are skipped // by both proofing paths; English still is proofed. let mut s = s(); run(&mut s, "text.insert", json!({"text": "ąžuolas labas"})); let before = run(&mut s, "review.issues", json!({})); assert!(!before.as_array().map(|a| a.is_empty()).unwrap_or(true), "default language flags the words: {before}"); run(&mut s, "select.all", json!({})); run(&mut s, "review.language", json!({"lang": "lt-LT"})); let after = run(&mut s, "review.issues", json!({})); assert_eq!(after, json!([]), "Lithuanian runs are skipped: {after}"); // English typos are still caught. let mut e = Session::new(wordcraft_doc::Document::new()); run(&mut e, "text.insert", json!({"text": "Hello helo world"})); let en = run(&mut e, "review.issues", json!({})); assert_eq!(en.as_array().map(|a| a.len()).unwrap_or(0), 1, "English typo still flagged: {en}"); } #[test] fn typing_enter_undo() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Hello"})); run(&mut s, "text.insert", json!({"text": " world"})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "Second"})); assert_eq!(text(&s), "Hello world\nSecond"); run(&mut s, "edit.undo", json!({})); assert_eq!(text(&s), "Hello world\n"); run(&mut s, "edit.undo", json!({})); assert_eq!(text(&s), "Hello world"); run(&mut s, "edit.undo", json!({})); assert_eq!(text(&s), ""); run(&mut s, "edit.redo", json!({})); assert_eq!(text(&s), "Hello world"); } /// The Undo button's list (#701): several steps undone at once, newest first. #[test] fn undo_to_takes_several_steps_back_at_once() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Hello"})); run(&mut s, "select.all", json!({})); run(&mut s, "format.bold", json!({})); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "World"})); let h = run(&mut s, "edit.undoHistory", json!({})); assert_eq!(h["undo"], json!(["Typing", "New Paragraph", "Bold", "Typing"])); assert_eq!(h["depth"], 4); assert_eq!(s.undo_history(), ["Typing", "New Paragraph", "Bold", "Typing"]); // Three steps in one command: World, the paragraph and the bold go; Hello stays. let r = run(&mut s, "edit.undoTo", json!({"count": 3})); assert_eq!(r["undone"], 3); assert_eq!(r["depth"], 1); assert_eq!(text(&s), "Hello"); assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(1).bold), Some(None)); assert_eq!(s.undo_history(), ["Typing"]); // Each undone step is one redo step. run(&mut s, "edit.redo", json!({})); run(&mut s, "edit.redo", json!({})); run(&mut s, "edit.redo", json!({})); assert_eq!(text(&s), "Hello\nWorld"); // More than the depth undoes everything. let r = run(&mut s, "edit.undoTo", json!({"count": 1e300})); assert_eq!(r["undone"], 4); assert_eq!(text(&s), ""); assert!(!s.can_undo()); assert!(s.run("edit.undoTo", &json!({"count": 1})).is_err(), "nothing to undo"); } #[test] fn undo_to_rejects_bad_counts_and_leaves_the_document_alone() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Hello"})); run(&mut s, "text.newParagraph", json!({})); let before = (s.doc.clone(), s.undo_depth()); for junk in [json!({}), json!(null), json!({"count": 0}), json!({"count": -3}), json!({"count": "2"}), json!({"count": [2]}), json!({"count": -1e308})] { assert!(s.run("edit.undoTo", &junk).is_err(), "{junk}"); assert_eq!((s.doc.clone(), s.undo_depth()), before, "{junk}"); } // A fraction counts whole steps. let r = run(&mut s, "edit.undoTo", json!({"count": 1.9})); assert_eq!(r["undone"], 1); assert_eq!(text(&s), "Hello"); } #[test] fn undo_history_is_capped() { let mut s = s(); for i in 0..(crate::UNDO_LIST_MAX + 20) { run(&mut s, "para.indents", json!({"left": i as f64})); } assert_eq!(s.undo_history().len(), crate::UNDO_LIST_MAX); assert_eq!(run(&mut s, "edit.undoHistory", json!({}))["undo"].as_array().map(Vec::len), Some(crate::UNDO_LIST_MAX)); } #[test] fn joined_commands_are_one_undo_step() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Hello"})); run(&mut s, "para.indents", json!({"left": 9.0})); run(&mut s, "para.indents", json!({"left": 18.0})); for x in [27.0, 36.0] { s.join_next_undo(); run(&mut s, "para.indents", json!({"left": x})); } let indent = |s: &Session| s.doc.para_at(&s.sel.focus).and_then(|p| p.props.indent_left); assert_eq!(indent(&s), Some(36.0)); // One undo reverts the whole second drag, not just its last frame. run(&mut s, "edit.undo", json!({})); assert_eq!(indent(&s), Some(9.0)); run(&mut s, "edit.undo", json!({})); assert_eq!(indent(&s), None); assert_eq!(text(&s), "Hello"); run(&mut s, "edit.redo", json!({})); run(&mut s, "edit.redo", json!({})); assert_eq!(indent(&s), Some(36.0)); } #[test] fn restore_takes_a_command_back_exactly() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "alpha beta"})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "gamma"})); run(&mut s, "edit.undo", json!({})); assert!(s.can_redo()); let (doc, sel, labels, depth, rev) = (s.doc.clone(), s.sel.clone(), s.undo_labels(), s.undo_depth(), s.rev()); s.dirty = false; let snap = s.edit_snapshot(); assert_eq!(snap.doc(), &doc); assert_eq!(snap.undo_depth(), depth); assert!(!snap.dirty()); run(&mut s, "select.all", json!({})); run(&mut s, "format.bold", json!({})); assert_ne!(s.doc, doc); assert_eq!(s.undo_depth(), depth + 1); assert!(!s.can_redo(), "a new command clears redo"); s.restore(snap); assert_eq!(s.doc, doc); assert_eq!(s.sel, sel); assert_eq!(s.undo_labels(), labels, "the command's undo step is gone"); assert!(s.can_redo(), "redo is back"); assert!(!s.dirty); assert!(s.rev() > rev, "the layout is recomputed"); run(&mut s, "edit.redo", json!({})); assert_eq!(text(&s), "alpha beta\ngamma"); } #[test] fn restore_is_exact_at_the_undo_limit() { // 510 steps, each with its own indent: the stack holds the newest 500. let build = || { let mut s = s(); run(&mut s, "text.insert", json!({"text": "alpha"})); for i in 0..510 { run(&mut s, "para.indents", json!({"left": f64::from(i)})); } s }; // What undo walks through: (label, indent) for every step, newest first. let walk = |s: &mut Session| { let mut steps = Vec::new(); while let Some(label) = s.undo_label().map(str::to_string) { s.undo(); steps.push((label, s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.indent_left))); } steps }; let expected = walk(&mut build()); assert_eq!(expected.len(), 500); // One command, and several (each pushes the oldest step out of the full stack). for n in [1, 2, 4] { let mut s = build(); let snap = s.edit_snapshot(); for _ in 0..n { run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); } assert_eq!(s.undo_label(), Some("Table")); assert_eq!(s.undo_depth(), 500); s.restore(snap); assert_eq!(walk(&mut s), expected, "{n} command(s): the oldest steps are back, the commands' steps are gone"); } // A command that fails at the limit puts its eviction back with the stacks, so a later // restore doesn't bring back a step that never left. let mut s = build(); let snap = s.edit_snapshot(); assert!(s.run("para.align", &json!({"value": "bogus"})).is_err()); run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); s.restore(snap); assert_eq!(walk(&mut s), expected, "a failed command between snapshot and restore"); // An automatic change recorded after the fact (`push_undo`) at the limit counts its eviction. let mut s = build(); let snap = s.edit_snapshot(); let (doc, sel) = (s.doc.clone(), s.sel.clone()); s.push_undo("AutoCorrect", doc, sel); s.restore(snap); assert_eq!(walk(&mut s), expected, "push_undo between snapshot and restore"); } #[test] fn restore_keeps_the_open_undo_step() { // Typing in progress: text typed after the restore joins the same undo step. let mut s = s(); run(&mut s, "text.insert", json!({"text": "alpha"})); let depth = s.undo_depth(); let snap = s.edit_snapshot(); run(&mut s, "para.alignCenter", json!({})); s.restore(snap); run(&mut s, "text.insert", json!({"text": " beta"})); assert_eq!(s.undo_depth(), depth, "still one typing step"); // A drag in progress (`join_next_undo`): its next frame joins the same undo step. run(&mut s, "para.indents", json!({"left": 9.0})); s.join_next_undo(); let depth = s.undo_depth(); let snap = s.edit_snapshot(); run(&mut s, "para.indents", json!({"left": 18.0})); s.restore(snap); run(&mut s, "para.indents", json!({"left": 27.0})); assert_eq!(s.undo_depth(), depth, "still one drag step"); run(&mut s, "edit.undo", json!({})); assert_eq!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.indent_left), None); } #[test] fn failed_command_keeps_undo_and_redo() { // A command that fails after its undo checkpoint leaves the history exactly as it was: // the redo stack survives, and no empty undo step is added. for (id, params) in [("text.insert", json!({})), ("para.align", json!({"value": "bogus"}))] { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Hello"})); run(&mut s, "edit.undo", json!({})); assert!(s.can_redo()); assert!(s.run(id, ¶ms).is_err(), "{id} should fail"); assert!(s.can_redo(), "{id}: redo lost"); assert!(!s.can_undo(), "{id}: failed command left an undo step"); run(&mut s, "edit.redo", json!({})); assert_eq!(text(&s), "Hello", "{id}"); } // At the undo limit, the oldest step is not evicted by a command that fails. let mut s = s(); let steps = 600; for i in 1..=steps { run(&mut s, "para.indents", json!({"left": i as f32})); } let kept = s.undo_labels().len(); assert!(kept < steps, "the history should be at its limit"); assert!(s.run("para.align", &json!({"value": "bogus"})).is_err()); assert_eq!(s.undo_labels().len(), kept); while s.can_undo() { run(&mut s, "edit.undo", json!({})); } let indent = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.indent_left); assert_eq!(indent, Some((steps - kept) as f32)); } #[test] fn failed_command_restores_history_changed_by_nested_commands() { // `file.inspect` runs `review.deleteComment` (allowed in a comments-only document, and it // checkpoints), then `review.acceptAll` (refused). The refusal must leave the undo and // redo stacks exactly as they were, including at and next to the history limit, where the // outer and nested checkpoints each evict the oldest step. fn timeline(s: &mut Session) -> Vec<(Vec, wordcraft_doc::Document)> { let mut out = Vec::new(); // Straight through the history (the commands won't undo past Restrict Editing). while s.can_redo() { s.redo(); } loop { // Comments are stamped with the time to the second, and the two sessions compared // are built a moment apart, so leave the stamp out of the comparison. let mut doc = s.doc.clone(); doc.comments.values_mut().for_each(|c| c.date.clear()); out.push((s.undo_labels(), doc)); if !s.can_undo() { return out; } s.undo(); } } let kept = { let mut s = s(); for i in 1..=600 { run(&mut s, "para.indents", json!({"left": i as f32})); } s.undo_labels().len() }; // Below the limit there is room for a redo step too; at the limit an Undo would leave one free. for (len, redo) in [(kept - 1, Some("New Comment")), (kept, None)] { let session = || { let mut s = s(); run(&mut s, "text.insert", json!({"text": "note"})); for i in 1..=len + usize::from(redo.is_some()) - 4 { run(&mut s, "para.indents", json!({"left": i as f32})); } run(&mut s, "review.restrict", json!({"mode": "comments"})); run(&mut s, "review.newComment", json!({"text": "one"})); run(&mut s, "review.newComment", json!({"text": "two"})); if redo.is_some() { run(&mut s, "edit.undo", json!({})); } assert_eq!(s.undo_labels().len(), len); assert_eq!(s.redo_label(), redo); s }; let mut failed = session(); let err = failed.run("file.inspect", &json!({"remove": ["comments", "revisions"]})); assert!(matches!(err, Err(crate::CmdError::Disabled(_))), "{len}: {err:?}"); assert_eq!(failed.undo_labels().len(), len, "{len}: undo steps"); assert_eq!(failed.redo_label(), redo, "{len}: redo step"); assert!(timeline(&mut failed) == timeline(&mut session()), "{len}: undo history changed"); } } #[test] fn backspace_and_delete() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "abc"})); run(&mut s, "text.backspace", json!({})); assert_eq!(text(&s), "ab"); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "cd"})); run(&mut s, "caret.home", json!({})); run(&mut s, "text.backspace", json!({})); // Enter capitalised the first word, as Word's AutoCorrect does. assert_eq!(text(&s), "Abcd"); run(&mut s, "caret.docStart", json!({})); run(&mut s, "text.delete", json!({})); assert_eq!(text(&s), "bcd"); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "text.deleteWordBack", json!({})); assert_eq!(text(&s), ""); } /// Backspace at the start of the paragraph after a table nested in a cell goes into the nested /// table's last cell, not into the last cell of the table around it. #[test] fn backspace_after_a_nested_table_stays_in_its_cell() { let mut s = s(); run(&mut s, "table.quick", json!({"kind": "matrix"})); run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 0}})); run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); let outer = s.doc.body.get(1).and_then(|b| b.as_table()).expect("the outer table"); let blocks = &outer.rows[1].cells[1].blocks; let nested = blocks.iter().position(|b| b.as_table().is_some()).expect("a nested table"); assert!(nested + 1 < blocks.len(), "a paragraph follows the nested table"); let after = nested as u32 + 1; run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, after], "off": 0}})); run(&mut s, "text.backspace", json!({})); // The nested table's last cell: row 1, cell 1, its first paragraph. assert_eq!(s.sel.focus.path.0, vec![1, 1, 1, after - 1, 1, 1, 0]); } #[test] fn bold_toggles_selection_and_caret_word() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "make this bold"})); run(&mut s, "select.text", json!({"text": "this"})); run(&mut s, "format.bold", json!({})); let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); assert_eq!(p.props_of_char(5).bold, Some(true)); assert_eq!(p.props_of_char(0).bold, None); run(&mut s, "format.bold", json!({})); assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).bold, Some(false)); // Caret inside a word formats the word. run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [0], "off": 11}})); run(&mut s, "format.italic", json!({})); let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); assert_eq!(p.props_of_char(10).italic, Some(true)); assert_eq!(p.props_of_char(13).italic, Some(true)); assert_eq!(p.props_of_char(9).italic, None); } #[test] fn border_toggles_and_saves() { use wordcraft_doc::props::Border; let border = |s: &Session| s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).border; // Saved .docx carries the border iff it is on (read back: the reader maps only `w:bdr` to `border`). let docx_has_bdr = |s: &Session| { let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); back.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).border.is_some() }; let mut s = s(); run(&mut s, "text.insert", json!({"text": "make this boxed"})); run(&mut s, "select.text", json!({"text": "this"})); run(&mut s, "format.border", json!({})); assert_eq!(border(&s), Some(Border::single(0.5))); assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).border, None); assert!(docx_has_bdr(&s)); run(&mut s, "format.border", json!({})); assert_eq!(border(&s), None); run(&mut s, "format.border", json!({"value": true})); run(&mut s, "format.border", json!({"value": true})); assert_eq!(border(&s), Some(Border::single(0.5))); run(&mut s, "format.border", json!({"value": false})); assert_eq!(border(&s), None); assert!(!docx_has_bdr(&s)); } #[test] fn pending_format_applies_to_typing() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "a "})); run(&mut s, "format.bold", json!({})); run(&mut s, "text.insert", json!({"text": "b"})); let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); assert_eq!(p.props_of_char(2).bold, Some(true)); assert_eq!(p.props_of_char(0).bold, None); } #[test] fn styles_and_lists() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Title"})); run(&mut s, "para.heading1", json!({})); run(&mut s, "text.newParagraph", json!({})); // Heading's next style is Normal. assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props.style.as_deref(), Some("Normal")); run(&mut s, "para.bullets", json!({})); run(&mut s, "text.insert", json!({"text": "one"})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "two"})); let n1 = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap(); let n2 = s.doc.para_at(&Pos::body(2, 0)).unwrap().props.numbering.unwrap(); assert_eq!(n1.num, n2.num); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.newParagraph", json!({})); // empty item ends the list assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_none_or(|n| n.num == 0)); // "1. " autoformat. run(&mut s, "text.insert", json!({"text": "1."})); run(&mut s, "text.insert", json!({"text": " "})); assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_some_and(|n| n.num != 0)); } #[test] fn applying_a_heading_clears_list_numbering() { // Applying a heading (or Title/Normal) to a numbered paragraph removes its direct list // numbering. A dead `&& id == "Normal"` term used to let only Normal clear it, so headings // kept the numbering. for style_cmd in ["para.heading1", "para.heading2", "para.normal"] { let mut s = s(); run(&mut s, "text.insert", json!({"text": "item"})); run(&mut s, "para.bullets", json!({})); assert!( s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0), "{style_cmd}: expected list numbering before applying the style" ); run(&mut s, style_cmd, json!({})); assert!( s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_none_or(|n| n.num == 0), "{style_cmd}: should clear direct list numbering" ); } } #[test] fn find_replace() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "cat dog cat\nCat bird"})); let r = run(&mut s, "edit.find", json!({"text": "cat"})); assert_eq!(r["count"], 3); let r = run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "fox", "matchCase": true})); assert_eq!(r["replaced"], 2); assert_eq!(text(&s), "fox dog fox\nCat bird"); let r = run(&mut s, "edit.find", json!({"text": "\\b\\w{3}\\b", "regex": true, "matchCase": false})); assert_eq!(r["count"], 4); } #[test] fn find_replace_special_codes() { // ^p finds paragraph marks across paragraphs; Replace All with ^p splits paragraphs and is one undo step. let mut s = s(); run(&mut s, "document.setText", json!({"text": "one\ntwo\nthree"})); let paras = |s: &Session| s.doc.para_paths(StoryRef::Body).len(); assert_eq!(run(&mut s, "edit.find", json!({"text": "^p"}))["count"], 2); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "o^pt", "with": "O+T"}))["replaced"], 1); assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwO+Three", 2)); run(&mut s, "edit.undo", json!({})); assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwo\nthree", 3)); run(&mut s, "document.setText", json!({"text": "a, b, c"})); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": ", ", "with": "^p"}))["replaced"], 2); assert_eq!((text(&s).as_str(), paras(&s)), ("a\nb\nc", 3)); run(&mut s, "edit.undo", json!({})); assert_eq!((text(&s).as_str(), paras(&s)), ("a, b, c", 1)); // ^t, ^# and ^& (the found text), ^^ is a caret. run(&mut s, "document.setText", json!({"text": "x\ty 12 a^b"})); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^t", "with": " "}))["replaced"], 1); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^#", "with": "<^&>"}))["replaced"], 2); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^^", "with": "-"}))["replaced"], 1); assert_eq!(text(&s), "x y <1><2> a-b"); // `special: false` reads the text literally. assert_eq!(run(&mut s, "edit.find", json!({"text": "^#", "special": false}))["count"], 0); } #[test] fn find_replace_wildcards() { let mut s = s(); // Groups and back-references swap words; Use wildcards matches case. run(&mut s, "document.setText", json!({"text": "Smith, John and Doe, Jane; smith, x"})); let r = run(&mut s, "edit.replaceAll", json!({"text": "([A-Z][a-z]@), ([A-Z][a-z]@)", "with": "\\2 \\1", "wildcards": true})); assert_eq!(r["replaced"], 2); assert_eq!(text(&s), "John Smith and Jane Doe; smith, x"); // < and > anchor to word starts and ends; * is the shortest run; {n} counts. run(&mut s, "document.setText", json!({"text": "cat concat cats scatter"})); assert_eq!(run(&mut s, "edit.find", json!({"text": "", "wildcards": true}))["count"], 2); assert_eq!(run(&mut s, "edit.find", json!({"text": "", "wildcards": true}))["count"], 2); assert_eq!(run(&mut s, "edit.find", json!({"text": "t{2}", "wildcards": true}))["count"], 1); assert_eq!(run(&mut s, "edit.find", json!({"text": "[!c]at", "wildcards": true}))["count"], 0); assert_eq!(run(&mut s, "edit.find", json!({"text": "[!s ]cat", "wildcards": true}))["count"], 1); // Options of the normal search: prefix, suffix, ignore punctuation. assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "wildcards": false, "matchPrefix": true}))["count"], 2); assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "matchPrefix": false, "matchSuffix": true}))["count"], 2); run(&mut s, "document.setText", json!({"text": "e.g. eg"})); assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2); } #[test] fn hostile_find_patterns_are_bounded() { let mut s = s(); run(&mut s, "document.setText", json!({"text": &"a".repeat(5000)})); // Patterns that backtrack catastrophically elsewhere finish quickly (linear-time engine). let t = std::time::Instant::now(); for q in ["(a@)@b", "(*)@b", "(a{1,1000}){1,1000}", "(((((a*)*)*)*)*)b"] { let _ = s.run("edit.find", &json!({"text": q, "wildcards": true})); } assert!(t.elapsed().as_secs() < 20); // Bad and oversized patterns are errors, not crashes. for q in ["[z-a]", "a{5", "(ab", "\\1", "x{2000}", "[", "@"] { assert!(s.run("edit.find", &json!({"text": q, "wildcards": true})).is_err(), "{q}"); } assert!(s.run("edit.find", &json!({"text": "x".repeat(2000), "wildcards": false})).is_err()); for q in ["^", "^p^", "^u", "^99999999", "^0", "a^"] { let _ = s.run("edit.replaceAll", &json!({"text": q, "with": "^&^c\\9^p^", "wildcards": false})); } } #[test] fn replace_under_track_changes_skips_deleted_text() { // Replace marks the match deleted and leaves it in the text; the next // step must not find it again, or Replace never advances. let mut s = s(); run(&mut s, "document.setText", json!({"text": "one cat two cat"})); run(&mut s, "review.trackChanges", json!({"value": true})); run(&mut s, "caret.docStart", json!({})); assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 2); let mut remaining = Vec::new(); for _ in 0..4 { remaining.push(run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"}))["remaining"].clone()); } assert_eq!(remaining, [json!(1), json!(0), json!(0), json!(0)]); assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 0); run(&mut s, "review.acceptAll", json!({})); assert_eq!(text(&s), "one dog two dog"); // A match that runs across tracked-deleted text is not in the document. let mut s = self::s(); run(&mut s, "document.setText", json!({"text": "cat"})); run(&mut s, "review.trackChanges", json!({"value": true})); run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 1}, "focus": {"block": 0, "off": 2}})); run(&mut s, "text.delete", json!({})); assert_eq!(text(&s), "cat", "the deletion is tracked, not applied"); assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 0); assert_eq!(run(&mut s, "edit.find", json!({"text": "t"}))["count"], 1); // Without Track Changes, Replace steps through every match as before. let mut s = self::s(); run(&mut s, "document.setText", json!({"text": "one cat two cat"})); run(&mut s, "caret.docStart", json!({})); run(&mut s, "edit.find", json!({"text": "cat"})); run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"})); let r = run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"})); assert_eq!(r["remaining"], 0); assert_eq!(text(&s), "one dog two dog"); } #[test] fn find_reads_the_text_around_tracked_deletions() { // `edits` are (start, end) byte ranges deleted with Track Changes on, last first. fn tracked(text: &str, edits: &[(usize, usize)]) -> Session { let mut s = self::s(); run(&mut s, "document.setText", json!({"text": text})); run(&mut s, "review.trackChanges", json!({"value": true})); for (a, b) in edits { run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}})); run(&mut s, "text.delete", json!({})); } assert_eq!(text, self::text(&s), "the deletions are tracked, not applied"); s } let offs = |r: &serde_json::Value| -> Vec<(u64, u64)> { r["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect() }; // "aaa" with the first "a" deleted reads "aa": the rejected raw match 0..2 must not hide // the live one at 1..3. let mut s = tracked("aaa", &[(0, 1)]); assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "aa"}))), [(1, 3)]); assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "^a", "regex": true}))), [(1, 2)]); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "aa", "with": "b"}))["replaced"], 1); // Whole words follow the live text: deleting the space joins "cat" and "fish"... let mut s = tracked("cat fish", &[(3, 4)]); assert_eq!(run(&mut s, "edit.find", json!({"text": "fish", "wholeWord": true}))["count"], 0); assert_eq!(run(&mut s, "edit.find", json!({"text": "catfish", "wholeWord": true}))["count"], 0, "a match may not span a deletion"); assert_eq!(run(&mut s, "edit.find", json!({"text": "fish", "wholeWord": false}))["count"], 1); // ...and deleting the "X" leaves "fish" a word of its own. let mut s = tracked("cat Xfish", &[(4, 5)]); assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "FISH", "wholeWord": true}))), [(5, 9)]); assert_eq!(run(&mut s, "edit.find", json!({"text": "FISH", "wholeWord": true, "matchCase": true}))["count"], 0); // Replace and the Find Next/Previous steps agree with Find. let mut s = tracked("aaa aaa", &[(4, 5), (0, 1)]); run(&mut s, "caret.docStart", json!({})); assert_eq!(run(&mut s, "edit.find", json!({"text": "aa"}))["count"], 2); assert_eq!(run(&mut s, "edit.findNext", json!({}))["index"], 1); assert_eq!(run(&mut s, "edit.findPrevious", json!({}))["index"], 0); let r = run(&mut s, "edit.replace", json!({"text": "aa", "with": "b"})); assert_eq!(r["remaining"], 1); } #[test] fn find_next_with_options_visits_every_match() { // The Find dialog sends its options with each Find Next (#468): every click is one step. let opts = json!({"text": "cat", "with": "", "matchCase": false, "wholeWord": false, "regex": false}); let starts = |text: &str, clicks: usize, cmd: &str| { let mut s = s(); run(&mut s, "document.setText", json!({"text": text})); run(&mut s, "caret.docStart", json!({})); (0..clicks).map(|_| run(&mut s, cmd, opts.clone())["index"].as_u64().unwrap()).collect::>() }; assert_eq!(starts("cat cat cat cat", 5, "edit.findNext"), [0, 1, 2, 3, 0]); assert_eq!(starts("cat cat", 3, "edit.findNext"), [0, 1, 0]); assert_eq!(starts("cat cat cat", 4, "edit.findPrevious"), [2, 1, 0, 2]); // A new query takes effect at once, without a separate `edit.find`. let mut s = s(); run(&mut s, "document.setText", json!({"text": "cat dog cat dog"})); run(&mut s, "caret.docStart", json!({})); assert_eq!(run(&mut s, "edit.findNext", opts.clone())["index"], 0); let r = run(&mut s, "edit.findNext", json!({"text": "dog"})); assert_eq!((r["index"].clone(), r["count"].clone()), (json!(0), json!(2))); assert_eq!(s.sel.ordered().0.off, 4); } #[test] fn clipboard_round_trip() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "alpha beta\ngamma"})); run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 1, "off": 2}})); run(&mut s, "edit.copy", json!({})); assert_eq!(s.clipboard_text, "beta\nga"); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "edit.paste", json!({})); assert_eq!(text(&s), "alpha beta\ngammabeta\nga"); run(&mut s, "select.all", json!({})); run(&mut s, "edit.cut", json!({})); assert_eq!(text(&s), ""); run(&mut s, "edit.paste", json!({"text": "plain\ntext"})); assert_eq!(text(&s), "plain\ntext"); } /// Equations in the paragraphs of a session's body, as linear text. fn equations(s: &Session) -> Vec { let mut out = Vec::new(); for b in &s.doc.body { if let wordcraft_doc::Block::Para(p) = &**b { for o in &p.objects { if let wordcraft_doc::para::InlineObject::Equation { linear, .. } = o { out.push(linear.clone()); } } } } out } #[test] fn copying_an_equation_puts_mathml_on_the_clipboard() { // #816: other programs get the equation as MathML in HTML, beside the linear text. let mut s = s(); run(&mut s, "document.setText", json!({"text": "Speed "})); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "insert.equation", json!({"linear": "y=2x_s", "edit": false})); run(&mut s, "select.all", json!({})); let r = run(&mut s, "edit.copy", json!({})); let html = r["html"].as_str().unwrap_or_default().to_string(); assert!(html.contains(""), "{html}"); assert!(html.contains("xs"), "{html}"); assert!(html.contains("Speed"), "{html}"); assert_eq!(s.clipboard_html, html); assert!(r["text"].as_str().unwrap_or_default().contains("y=2x_s")); let clip = r["text"].as_str().unwrap_or_default().to_string(); // Pasting it into another document reads the equation back from the HTML. let mut t = Session::new(wordcraft_doc::Document::new()); run(&mut t, "edit.paste", json!({"text": clip, "html": html})); assert_eq!(equations(&t), vec!["y=2x_s".to_string()]); assert!(text(&t).starts_with("Speed"), "{}", text(&t)); // And back into this one, from our own copy. run(&mut s, "caret.docEnd", json!({})); run(&mut s, "edit.paste", json!({"text": clip, "html": html})); assert_eq!(equations(&s), vec!["y=2x_s".to_string(), "y=2x_s".to_string()]); // A copy without an equation carries HTML too (#1523). run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}})); let r = run(&mut s, "edit.copy", json!({})); assert!(r["html"].as_str().is_some_and(|h| h.contains("Speed") && !h.contains(" bool { let mut stack: Vec = Vec::new(); let mut closed_root = false; let mut rest = xml; while let Some(lt) = rest.find('<') { if closed_root || (stack.is_empty() && !rest[..lt].trim().is_empty()) { return false; } let Some(gt) = rest[lt..].find('>') else { return false }; let tag = &rest[lt + 1..lt + gt]; rest = &rest[lt + gt + 1..]; if let Some(name) = tag.strip_prefix('/') { if stack.pop().as_deref() != Some(name.trim()) { return false; } closed_root = stack.is_empty(); } else if !tag.ends_with('/') { stack.push(tag.split_whitespace().next().unwrap_or_default().to_string()); } else if stack.is_empty() { closed_root = true; } } closed_root && stack.is_empty() && rest.trim().is_empty() } #[test] fn copying_one_equation_gives_its_mathml_for_the_math_clipboard_formats() { // #1297: Word reads a pasted equation first from the clipboard's "MathML" formats; a copy of // one equation alone carries its MathML for the host to put there. let cases = [ ("y=(a+b)/(c-d)", ""), ("x_i^2+e^(-t)", ""), ("√(x^2+1)+∛8", ""), ("∑_(k=1)^n k^2", ""), ("∫_0^1 f(x)dx", "∫"), ("■(1&0@0&1)", ""), ]; for (linear, element) in cases { let mut s = s(); run(&mut s, "insert.equation", json!({"linear": linear, "edit": false})); run(&mut s, "select.all", json!({})); let r = run(&mut s, "edit.copy", json!({})); let mathml = r["mathml"].as_str().unwrap_or_default().to_string(); assert!(mathml.starts_with("")) { (Some(a), Some(b)) => format!("{}{}", &mathml[..a], &mathml[b + "".len()..]), _ => mathml.clone(), }; t = s_with_paste(&bare); assert_eq!(equations(&t).len(), 1, "{linear}: {bare}"); } } fn s_with_paste(html: &str) -> Session { let mut t = s(); run(&mut t, "edit.paste", json!({"text": "", "html": html})); t } #[test] fn copying_an_equation_with_text_offers_no_math_clipboard_formats() { // #1297: Word takes the "MathML" format before the HTML, so offering it for a copy with text // around the equation would drop the text. Text alone has none either. let mut s = s(); run(&mut s, "document.setText", json!({"text": "Speed "})); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "insert.equation", json!({"linear": "y=2x_s", "edit": false})); run(&mut s, "select.all", json!({})); let r = run(&mut s, "edit.copy", json!({})); assert!(r.get("mathml").is_none() && r["html"].as_str().is_some_and(|h| h.contains("") && !m.contains("display=")), "an inline equation: {r}"); } #[test] fn copying_bold_text_puts_html_on_the_clipboard() { // #1523: formatted text reaches other programs as HTML, not only as plain text. let mut s = s(); run(&mut s, "document.setText", json!({"text": "plain bold"})); s.doc.format_range(&Pos::body(0, 6), &Pos::body(0, 10), &|c| c.bold = Some(true)).unwrap(); run(&mut s, "select.all", json!({})); let r = run(&mut s, "edit.copy", json!({})); let html = r["html"].as_str().unwrap_or_default().to_string(); assert!(html.contains("bold"), "{html}"); assert_eq!(s.clipboard_html, html); } #[test] fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "ok J\u{F0FC}"})); s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 7), &|c| c.font = Some("Wingdings".into())).unwrap(); run(&mut s, "select.all", json!({})); run(&mut s, "edit.copy", json!({})); assert_eq!(s.clipboard_text, "ok \u{263A}\u{2713}"); // Pasting our own copy back (the system clipboard holds the mapped text) keeps the stored // Wingdings characters, not the Unicode ones. run(&mut s, "caret.docEnd", json!({})); let clip = s.clipboard_text.clone(); run(&mut s, "edit.paste", json!({"text": clip})); assert_eq!(text(&s), "ok J\u{F0FC}ok J\u{F0FC}"); } /// The text drawn on the first page. fn drawn_text(s: &mut Session) -> String { let layout = s.layout(); let draws = wordcraft_layout::display::page_display(&s.doc, &layout.pages[0], &Default::default()); draws.iter().filter_map(|d| if let wordcraft_layout::display::Draw::Glyphs { text, .. } = d { Some(text.as_str()) } else { None }).collect() } #[test] fn an_inserted_symbol_keeps_its_font_when_the_text_font_changes() { // #2036: Insert › Symbol in Wingdings, then the whole document set in Times New Roman: the // symbol stays a check mark (as Word keeps a `w:sym` character's font). let mut s = s(); run(&mut s, "document.setText", json!({"text": "ab"})); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); run(&mut s, "text.insert", json!({"text": "c"})); run(&mut s, "select.all", json!({})); run(&mut s, "format.font", json!({"name": "Times New Roman"})); assert_eq!(text(&s), "ab\u{F0FC}c", "the stored character is unchanged"); assert_eq!(drawn_text(&mut s), "ab\u{2713}c"); run(&mut s, "edit.copy", json!({})); assert_eq!(s.clipboard_text, "ab\u{2713}c"); // Text typed after the symbol is in the text's font, not the symbol's. run(&mut s, "caret.docEnd", json!({})); run(&mut s, "text.insert", json!({"text": "J"})); assert_eq!(drawn_text(&mut s), "ab\u{2713}cJ"); // Clear Formatting keeps the symbol too. run(&mut s, "select.all", json!({})); run(&mut s, "format.clear", json!({})); assert_eq!(drawn_text(&mut s), "ab\u{2713}cJ"); } #[test] fn format_painter_does_not_carry_a_symbol_font_to_text() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "ok "})); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); run(&mut s, "text.insert", json!({"text": " J"})); // Pick up the symbol's formatting and paint it on "J". run(&mut s, "select.text", json!({"text": "\u{F0FC}"})); run(&mut s, "edit.formatPainter", json!({})); run(&mut s, "select.text", json!({"text": "J"})); run(&mut s, "edit.pasteFormat", json!({})); let shown = drawn_text(&mut s); assert!(shown.ends_with('J'), "{shown}"); } /// "ab" then a Wingdings check mark, with the caret after it. fn text_then_symbol() -> Session { let mut s = s(); run(&mut s, "document.setText", json!({"text": "ab"})); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); s } #[test] fn a_style_created_next_to_a_symbol_does_not_take_its_font() { let mut s = text_then_symbol(); let r = run(&mut s, "styles.create", json!({"name": "Mine"})); let id = r["id"].as_str().unwrap().to_string(); assert_eq!(s.doc.styles.get(&id).unwrap().chr.sym_font, None); assert_eq!(drawn_text(&mut s), "ab\u{2713}"); } #[test] fn updating_a_style_to_match_a_symbol_does_not_take_its_font() { let mut s = text_then_symbol(); run(&mut s, "select.text", json!({"text": "\u{F0FC}"})); run(&mut s, "styles.updateToMatch", json!({})); assert_eq!(s.doc.styles.get("Normal").unwrap().chr.sym_font, None); assert_eq!(drawn_text(&mut s), "ab\u{2713}"); } #[test] fn replacing_a_symbol_with_text_gives_the_text_no_symbol_font() { let mut s = text_then_symbol(); run(&mut s, "edit.replaceAll", json!({"text": "\u{F0FC}", "with": "OK"})); assert_eq!(text(&s), "abOK"); let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); assert_eq!(p.props_of_char(2).sym_font, None); assert_eq!(drawn_text(&mut s), "abOK"); } fn bold_at(s: &Session, block: usize, off: usize) -> bool { let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off }; s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false) } #[test] fn paste_special_lists_formats_and_pastes_text_without_formatting() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "Bold words"})); run(&mut s, "select.text", json!({"text": "Bold"})); run(&mut s, "format.bold", json!({})); run(&mut s, "edit.copy", json!({})); // No format: the formats are listed (and a front end is asked to show the dialog). let r = run(&mut s, "edit.pasteSpecial", json!({})); let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); assert_eq!(ids, ["formatted", "text"]); assert_eq!(s.ui_requests.last().unwrap()["open"], "pasteSpecial"); run(&mut s, "caret.docEnd", json!({})); let r = run(&mut s, "edit.pasteSpecial", json!({"as": "text"})); assert_eq!(r["pastedAs"], "text"); assert_eq!(text(&s), "Bold wordsBold"); assert!(bold_at(&s, 0, 0)); assert!(!bold_at(&s, 0, 11), "unformatted text drops the bold"); // Formatted keeps it. run(&mut s, "edit.pasteSpecial", json!({"as": "formatted"})); assert_eq!(text(&s), "Bold wordsBoldBold"); assert!(bold_at(&s, 0, 15)); // Text that someone else copied since: our rich copy is no longer offered. let r = run(&mut s, "edit.pasteSpecial", json!({"text": "elsewhere"})); assert_eq!(r["formats"].as_array().unwrap().len(), 1); assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err()); } #[test] fn pasted_rtf_tracked_changes_point_at_their_own_revisions() { // Gap 19: RTF tracked changes come with revisions of their own; pasted into a document that // already has revisions, they must not point at the document's (index 0 here is "Zed"). let mut s = s(); run(&mut s, "document.setText", json!({"text": "x"})); s.doc.revisions.push(wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Delete, "Zed", "")); run(&mut s, "caret.docEnd", json!({})); let rtf = r"{\rtf1{\*\revtbl {Unknown;}{Alice;}}{\revised\revauth1 added}\par}"; run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": rtf})); let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 2 }; let ins = s.doc.para_at(&pos).and_then(|p| p.props_of_char(2).ins); let rev = ins.and_then(|i| s.doc.revisions.get(i as usize)); assert_eq!(rev.map(|r| (r.kind, r.author.as_str())), Some((wordcraft_doc::RevisionKind::Insert, "Alice")), "{:?}", s.doc.revisions); } /// Insertion and deletion revision kinds at body paragraph 0, offset `off`. fn rev_kinds_at(s: &Session, off: usize) -> (Option, Option) { let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off }; let cp = s.doc.para_at(&pos).map(|p| p.props_of_char(off).clone()).unwrap_or_default(); let kind = |i: Option| i.and_then(|i| s.doc.revisions.get(i as usize)).map(|r| r.kind); (kind(cp.ins), kind(cp.del)) } const RTF_WITH_DELETION: &str = r"{\rtf1{\*\revtbl {Unknown;}{Bob;}}kept{\deleted\revauthdel1 gone}\par}"; #[test] fn pasted_rtf_deletions_stay_deleted_under_track_changes() { // Gap 19: with Track Changes on the paste is an insertion, and text deleted in the source is // inserted-then-deleted (struck), never live text. use wordcraft_doc::RevisionKind::{Delete, Insert}; let mut s = s(); run(&mut s, "document.setText", json!({"text": "x"})); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "review.trackChanges", json!({"value": true})); run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": RTF_WITH_DELETION})); assert_eq!(rev_kinds_at(&s, 1), (Some(Insert), None), "kept"); assert_eq!(rev_kinds_at(&s, 5), (Some(Insert), Some(Delete)), "gone"); run(&mut s, "review.acceptAll", json!({})); assert_eq!(text(&s), "xkept"); } #[test] fn paste_merge_formatting_keeps_rtf_deletions() { use wordcraft_doc::RevisionKind::Delete; let mut s = s(); run(&mut s, "document.setText", json!({"text": "x"})); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "edit.pasteMerge", json!({"rtf": RTF_WITH_DELETION})); assert_eq!(rev_kinds_at(&s, 1), (None, None), "kept"); assert_eq!(rev_kinds_at(&s, 5), (None, Some(Delete)), "gone"); run(&mut s, "review.acceptAll", json!({})); assert_eq!(text(&s), "xkept"); } #[test] fn pasted_rtf_sections_link_their_headers_to_the_previous() { // #1378: the RTF's own header part ids mean nothing in this document; a pasted section // break keeps its page but links its headers and footers to the previous section. let mut s = s(); run(&mut s, "document.setText", json!({"text": "x"})); let note = s.doc.add_part(wordcraft_doc::PartKind::Footnote, Vec::new()); let _comment = s.doc.add_part(wordcraft_doc::PartKind::Comment, Vec::new()); run(&mut s, "caret.docEnd", json!({})); let rtf = r"{\rtf1\ansi\sectd\lndscpsxn\titlepg{\header Head\par}{\footer Foot\par}{\headerf First\par} a\par\sect\sectd{\header Two\par} b\par}"; run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": rtf})); let secs = s.doc.sections(); assert_eq!(secs.len(), 2, "the pasted section break is kept"); assert!(secs[0].1.landscape, "with its page settings"); for (_, sp) in &secs { for (set, kind) in [(sp.headers, wordcraft_doc::PartKind::Header), (sp.footers, wordcraft_doc::PartKind::Footer)] { for id in [set.default, set.first, set.even].into_iter().flatten() { assert_eq!(s.doc.parts.get(&id).map(|p| p.kind), Some(kind), "section refers to part {id} (footnote is {note})"); } } } assert_unlinked(&s); } /// Every section break in the body has no headers, footers or first-page setting of its own. fn assert_unlinked(s: &Session) { for (i, sp) in s.doc.sections() { assert_eq!((sp.headers, sp.footers, sp.title_page), Default::default(), "section ending at block {i}"); } } #[test] fn text_from_file_unlinks_inserted_sections_headers() { // #1378: Insert › Text from File brings the file's section breaks with their pages, but // the file's header and footer ids are its own: they link to the previous section's. let dir = std::env::temp_dir().join(format!("wordcraft-text-from-file-sections-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let rtf = r"{\rtf1\ansi\sectd\lndscpsxn\titlepg{\header Head\par}{\footer Foot\par}{\headerf First\par} a\par\sect\sectd{\header Two\par} b\par}"; let rtf_path = dir.join("sections.rtf"); std::fs::write(&rtf_path, rtf).unwrap(); // The same document as DOCX. let docx_path = dir.join("sections.docx"); let src = crate::io::open_bytes("sections.rtf", rtf.as_bytes()).unwrap(); std::fs::write(&docx_path, crate::io::save_bytes("sections.docx", &src).unwrap()).unwrap(); for path in [&rtf_path, &docx_path] { let mut s = s(); run(&mut s, "document.setText", json!({"text": "x"})); s.doc.add_part(wordcraft_doc::PartKind::Footnote, Vec::new()); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "insert.textFromFile", json!({"path": path.to_str().unwrap()})); let secs = s.doc.sections(); assert!(secs.len() >= 2, "{path:?}: the inserted section break is kept"); assert!(secs[0].1.landscape, "{path:?}: with its page settings"); assert_unlinked(&s); } let _ = std::fs::remove_dir_all(&dir); } #[test] fn paste_special_html_and_rtf_keep_bold() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "x"})); run(&mut s, "caret.docEnd", json!({})); let html = "

plain strong

"; let r = run(&mut s, "edit.pasteSpecial", json!({"html": html, "text": "plain strong"})); let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); assert_eq!(ids, ["html", "text"]); run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": html})); assert_eq!(text(&s), "xplain strong"); assert!(!bold_at(&s, 0, 2)); assert!(bold_at(&s, 0, 8), "the run stays bold"); // HTML source copied as text can be pasted as HTML too. run(&mut s, "document.setText", json!({"text": ""})); run(&mut s, "edit.pasteSpecial", json!({"as": "html", "text": "hi there

"})); assert_eq!(text(&s), "hi there"); assert!(bold_at(&s, 0, 0) && !bold_at(&s, 0, 4)); // RTF. run(&mut s, "document.setText", json!({"text": ""})); run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": r"{\rtf1\ansi {\b bold}\b0 then}"})); assert!(text(&s).starts_with("bold"), "{}", text(&s)); assert!(bold_at(&s, 0, 0)); // Asking for a format the clipboard lacks is an error, not a panic. assert!(s.run("edit.pasteSpecial", &json!({"as": "rtf", "text": "plain"})).is_err()); assert!(s.run("edit.pasteSpecial", &json!({"as": "bogus", "text": "plain"})).is_err()); let huge = "a".repeat(crate::cmd::paste::MAX_PASTE_BYTES + 1); assert!(s.run("edit.pasteSpecial", &json!({"as": "text", "text": huge})).is_err()); } #[test] fn paste_special_respects_track_changes_and_brings_lists() { let mut s = s(); run(&mut s, "review.trackChanges", json!({"value": true})); run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": "
  • one
  • two
"})); assert!(text(&s).starts_with("one\ntwo"), "{}", text(&s)); let ch = run(&mut s, "review.changes", json!({})); assert!(!ch.as_array().unwrap().is_empty(), "the paste is a tracked insertion"); let first = s.doc.body.first().and_then(|b| b.as_para()).unwrap(); let n = first.props.numbering.expect("list kept"); assert!(s.doc.numbering.nums.iter().any(|x| x.id == n.num), "the list definition came along"); run(&mut s, "review.acceptAll", json!({})); assert!(text(&s).starts_with("one\ntwo")); } /// Character formatting at a body offset of the first paragraph. fn props_at(s: &Session, off: usize) -> wordcraft_doc::CharProps { let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off }; s.doc.para_at(&pos).map(|p| p.props_of_char(off).clone()).unwrap_or_default() } /// Paste of another program's formatted copy keeps its formatting (#687); Merge Formatting keeps /// only its emphasis, in the destination's font; Keep Text Only drops it all. #[test] fn paste_keeps_source_formatting_from_other_programs() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "x"})); run(&mut s, "caret.docEnd", json!({})); let html = "

red bold

"; let clip = json!({"text": "red bold", "html": html}); run(&mut s, "edit.paste", clip.clone()); assert_eq!(text(&s), "xred bold"); assert_eq!(props_at(&s, 1).font.as_deref(), Some("Courier New")); assert!(props_at(&s, 1).color.is_some()); assert!(!bold_at(&s, 0, 1) && bold_at(&s, 0, 5)); run(&mut s, "edit.undo", json!({})); assert_eq!(text(&s), "x"); run(&mut s, "edit.pasteMerge", clip.clone()); assert_eq!(text(&s), "xred bold"); assert_eq!(props_at(&s, 1).font, None, "Merge Formatting takes the destination's font"); assert_eq!(props_at(&s, 1).color, None); assert!(bold_at(&s, 0, 5), "and keeps the emphasis"); run(&mut s, "edit.undo", json!({})); run(&mut s, "edit.pasteText", clip); assert_eq!(text(&s), "xred bold"); assert!(!bold_at(&s, 0, 5)); // Windows' "HTML Format" carries a header with the fragment's byte offsets. run(&mut s, "document.setText", json!({"text": ""})); let body = "win"; let head = "Version:0.9\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:SSSSSSSSSS\r\nEndFragment:EEEEEEEEEE\r\n"; let start = head.len() + body.find("").unwrap(); let end = head.len() + body.find("\n

Báo cáo

tháng 9

", "\n

Báo cáo tháng 9

", ]; for (i, page) in pages.iter().enumerate() { for name in ["report.doc", "report.docx", "report.rtf"] { let doc = crate::io::open_bytes(name, page.as_bytes()).unwrap_or_else(|e| panic!("page {i} as {name}: {e}")); let t = text(&doc); assert!(t.contains("Báo cáo") && t.contains("tháng 9") && !t.contains('<'), "page {i} as {name}: {t:?}"); } } // A web page in an unknown extension is still plain text, other XML still no Word document. assert!(text(&crate::io::open_bytes("page.log", pages[1].as_bytes()).unwrap()).contains("")); assert!(crate::io::open_bytes("note.doc", b"hi").is_err()); // Nor is XML whose root only starts like `html`. for xml in [&b"text"[..], b"text"] { assert!(crate::io::open_bytes("other.doc", xml).is_err(), "{:?}", String::from_utf8_lossy(xml)); } } /// #569: a playlist (.m3u / .m3u8) — or any file with an extension WordCraft doesn't know — /// opens as plain text with its encoding detected, like Word's text import; a binary file is a /// clean error, never garbage or a panic. #[test] fn unknown_text_extensions_open_as_text() { let lines = |doc: &wordcraft_doc::Document| doc.plain_text(StoryRef::Body); let m3u8 = "#EXTM3U\r\n#EXTINF:215,Björk — Jóga\r\nmusic/Jóga.mp3\r\n#EXTINF:-1,Радио\r\nhttp://example.org/live\r\n"; let doc = crate::io::open_bytes("Mix.M3U8", m3u8.as_bytes()).unwrap(); assert!( lines(&doc).starts_with("#EXTM3U\n#EXTINF:215,Björk — Jóga\nmusic/Jóga.mp3\n#EXTINF:-1,Радио\nhttp://example.org/live"), "{:?}", lines(&doc) ); assert!(!lines(&doc).contains('\r')); // With a BOM, which must not show up as a character. let bom = [&[0xEF, 0xBB, 0xBF][..], m3u8.as_bytes()].concat(); assert!(lines(&crate::io::open_bytes("a.m3u8", &bom).unwrap()).starts_with("#EXTM3U\n")); // A plain .m3u in Windows-1252 (é = E9, — = 97). let cp1252 = b"#EXTM3U\r\n#EXTINF:100,Caf\xE9 \x97 Live\r\nC:\\Music\\caf\xE9.mp3\r\n"; assert_eq!(lines(&crate::io::open_bytes("old.m3u", cp1252).unwrap()), "#EXTM3U\n#EXTINF:100,Café — Live\nC:\\Music\\café.mp3\n"); // Other text files, a file without an extension, a dot in a folder name only. for name in ["server.log", "settings.ini", "subs.srt", "README", "/home/a.b/README", "C:\\v1.2\\notes"] { assert_eq!(lines(&crate::io::open_bytes(name, b"one\ntwo").unwrap()), "one\ntwo", "{name}"); } // Binary files: an error naming the file, no panic. for (name, bytes) in [("song.mp3", &b"ID3\x04\0\0\0\0\x01\x02\x03"[..]), ("pic.dat", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"), ("blob", &[0u8, 1, 2, 3, 0, 255])] { let err = crate::io::open_bytes(name, bytes).unwrap_err(); assert!(err.contains(name) && err.contains("not a document"), "{name}: {err}"); } assert!(crate::io::open_bytes("x.pdf", b"%PDF-1.7\n1 0 obj\n").is_err()); // A Word document or RTF file with the wrong extension opens by what it holds. let docx = crate::io::save_bytes("x.docx", &crate::sample::letter()).unwrap(); assert_eq!(lines(&crate::io::open_bytes("letter.bak", &docx).unwrap()), lines(&crate::io::open_bytes("x.docx", &docx).unwrap())); let rtf = crate::io::open_bytes("note.tmp", br"{\rtf1\ansi Hello {\b bold}\par}").unwrap(); assert!(lines(&rtf).starts_with("Hello bold"), "{:?}", lines(&rtf)); // Through file.open, both from disk and with the bytes (the web's picker and drops). let dir = std::env::temp_dir().join(format!("wordcraft-569-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("road trip.m3u"); std::fs::write(&path, cp1252).unwrap(); let mut s = s(); let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()})); assert_eq!(r["paragraphs"], 4); assert!(text(&s).contains("Café — Live")); let data = crate::cmd::insert::base64_encode(m3u8.as_bytes()); run(&mut s, "file.open", json!({"path": "Mix.m3u8", "data": data})); assert!(text(&s).contains("Радио")); let bad = crate::cmd::insert::base64_encode(b"\0\x01\x02binary\0"); assert!(s.run("file.open", &json!({"path": "x.bin", "data": bad})).is_err()); assert!(text(&s).contains("Радио"), "a failed open leaves the document alone"); let _ = std::fs::remove_dir_all(&dir); } /// #1384: File Explorer's New › Microsoft Word Document makes an empty, zero-byte .docx. It opens /// as a new blank document on the Normal template, and Save writes it back to that file instead /// of asking for a name. #[test] fn an_empty_file_opens_as_a_blank_document_saved_back_to_it() { let dir = std::env::temp_dir().join(format!("wordcraft-1384-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("New Microsoft Word Document.docx"); std::fs::write(&path, b"").unwrap(); let mut s = s(); run(&mut s, "file.options", json!({"region": "it-IT"})); s.set_editing_language(Some("it-IT")); run(&mut s, "text.insert", json!({"text": "An untitled draft"})); let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()})); assert_eq!((r["paragraphs"].as_u64(), r["words"].as_u64()), (Some(1), Some(0)), "{r}"); assert_eq!(text(&s), ""); assert_eq!(s.path.as_deref(), Some(path.as_path())); assert!(!s.dirty); let sp = &s.doc.last_section; assert_eq!((sp.page_w, sp.page_h), (595.28, 841.89), "the region's paper, as File › New gives"); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("it-IT"), "in the editing language"); run(&mut s, "text.insert", json!({"text": "Ciao"})); let r = run(&mut s, "file.save", json!({})); assert_eq!(r["saved"], true, "{r}"); assert!(!s.ui_requests.iter().any(|q| q["open"] == "saveAs"), "no Save As"); let saved = crate::io::open_path(&path).unwrap(); assert_eq!(saved.plain_text(StoryRef::Body), "Ciao"); // The same from the browser's picker (the web), which hands over the bytes. run(&mut s, "file.open", json!({"path": "Vuoto.docx", "data": ""})); assert_eq!((text(&s), s.doc.last_section.page_w), (String::new(), 595.28)); let _ = std::fs::remove_dir_all(&dir); } /// #549: after a double-click, dragging extends the selection by whole words; after a /// triple-click by whole paragraphs; after Ctrl/⌘+click by whole sentences. Forward drags keep /// the first unit's start as the anchor, backward drags its end. #[test] fn drag_after_multi_click_extends_by_units() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Alpha beta gamma. Delta epsilon.\rSecond para here.\rThird one."})); let drag = |s: &mut Session, unit: &str, start: Pos, end: Pos, pos: Pos| { run(s, "select.dragUnit", json!({"unit": unit, "start": start, "end": end, "pos": pos})); (s.sel.anchor.clone(), s.sel.focus.clone()) }; // Double-click "beta" (6..11 with its trailing space). run(&mut s, "caret.set", json!({"pos": Pos::body(0, 7)})); run(&mut s, "select.word", json!({})); let (a, b) = s.sel.ordered(); assert_eq!((a.off, b.off), (6, 11)); // Forward into the middle of "gamma": the whole word joins. assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 13)), (Pos::body(0, 6), Pos::body(0, 16))); // Back into "Alpha": anchored at the end of "beta ", focus at the start of "Alpha". assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 2)), (Pos::body(0, 11), Pos::body(0, 0))); // Back inside the first word: just that word. assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 8)), (Pos::body(0, 6), Pos::body(0, 11))); // Into the next paragraph: whole word there. assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(1, 8)), (Pos::body(0, 6), Pos::body(1, 12))); // Ctrl+click a sentence, drag into the next sentence. run(&mut s, "caret.set", json!({"pos": Pos::body(0, 3)})); run(&mut s, "select.sentence", json!({})); let (a, b) = s.sel.ordered(); let (_, f) = drag(&mut s, "sentence", a, b, Pos::body(0, 20)); assert_eq!(f.off, 32, "the second sentence is selected to its end"); // Triple-click the second paragraph, drag up into the first and down into the third. run(&mut s, "caret.set", json!({"pos": Pos::body(1, 3)})); run(&mut s, "select.paragraph", json!({})); let (a, b) = s.sel.ordered(); assert_eq!((a.clone(), b.clone()), (Pos::body(1, 0), Pos::body(2, 0))); assert_eq!(drag(&mut s, "paragraph", a.clone(), b.clone(), Pos::body(0, 5)), (Pos::body(2, 0), Pos::body(0, 0))); assert_eq!(drag(&mut s, "paragraph", a.clone(), b.clone(), Pos::body(2, 2)), (Pos::body(1, 0), Pos::body(2, 10))); // Junk never panics and leaves the document alone. let before = text(&s); for v in [ json!({}), json!({"unit": "page", "start": Pos::body(0, 0), "end": Pos::body(0, 1), "pos": Pos::body(0, 1)}), json!({"unit": "word", "start": Pos::body(99, 999), "end": Pos::body(0, 0), "pos": Pos::body(77, usize::MAX)}), json!({"unit": "word", "start": {"block": 0, "off": 9_999}, "end": {"block": 0, "off": 1}, "pos": {"block": 5}}), ] { let _ = s.run("select.dragUnit", &v); } assert_eq!(text(&s), before); } /// #856: a click in the selection bar selects the whole line (its trailing space; the /// paragraph mark on the last line); dragging extends by lines; triple-click takes the document. #[test] fn selection_bar_selects_and_drags_by_lines() { let mut s = s(); let long = "The quick brown fox jumps over the lazy dog. ".repeat(8); run(&mut s, "text.insert", json!({"text": format!("{}\rShort one.\rLast.", long.trim_end())})); let l = s.layout(); let c = l.caret(&Pos::body(0, 0)).unwrap(); let line2 = l.selection_bar_at(c.page, 20.0, c.top + c.height * 1.5).unwrap(); assert!(line2.off > 0); run(&mut s, "select.line", json!({"pos": line2.clone()})); let (a, b) = s.sel.ordered(); assert_eq!(a, line2); assert_eq!(b.path, wordcraft_doc::Path::top(0)); assert!(b.off > a.off && b.off < long.trim_end().len()); assert_eq!(s.doc.para_at(&b).unwrap().text.as_bytes()[b.off - 1], b' ', "the trailing space is in"); // The last line of a paragraph takes its mark. run(&mut s, "select.line", json!({"pos": Pos::body(1, 3)})); assert_eq!(s.sel.ordered(), (Pos::body(1, 0), Pos::body(2, 0))); // Drag from the "Short one." line down to "Last." and back up into the first paragraph. let (a, b) = s.sel.ordered(); run(&mut s, "select.dragUnit", json!({"unit": "line", "start": a.clone(), "end": b.clone(), "pos": Pos::body(2, 0)})); assert_eq!((s.sel.anchor.clone(), s.sel.focus.clone()), (Pos::body(1, 0), Pos::body(2, 5))); run(&mut s, "select.dragUnit", json!({"unit": "line", "start": a.clone(), "end": b.clone(), "pos": line2.clone()})); assert_eq!((s.sel.anchor.clone(), s.sel.focus.clone()), (Pos::body(2, 0), line2)); run(&mut s, "select.dragUnit", json!({"unit": "document", "start": a.clone(), "end": b.clone(), "pos": Pos::body(0, 0)})); assert_eq!(s.sel.ordered(), (Pos::body(0, 0), Pos::body(2, 5))); // Junk positions never panic. let _ = s.run("select.line", &json!({"pos": {"block": 99, "off": usize::MAX}})); let _ = s.run("select.line", &json!({"pos": 5})); } /// Home › Font › Enclose Characters (#297): the selection becomes an `EQ \o\ac` field, saved the /// way Word stores it (the style in the code's run sizes) and read back; null takes the text out /// again, and each step undoes. #[test] fn enclose_characters_make_an_eq_field_and_come_back_out() { use wordcraft_doc::InlineObject; let field = |s: &Session| { let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); p.objects.iter().find_map(|o| match o { InlineObject::Field { instr, result, .. } => Some((instr.clone(), result.clone())), _ => None, }) }; let mut s = s(); run(&mut s, "text.insert", json!({"text": "No 12 and 字 here"})); run(&mut s, "select.text", json!({"text": "No 12"})); assert!(s.run("format.enclose", &json!({"shape": "circle"})).is_err(), "five characters don't fit"); run(&mut s, "select.text", json!({"text": "字"})); assert!(s.run("format.enclose", &json!({})).is_err(), "a script must say which shape"); run(&mut s, "format.enclose", json!({"shape": "circle"})); assert_eq!(field(&s), Some(("eq \\o\\ac(○,字)".to_string(), "字".to_string()))); assert_eq!(text(&s), "No 12 and 字 here", "the field's text reads as the character"); run(&mut s, "select.text", json!({"text": "12"})); run(&mut s, "format.enclose", json!({"shape": "diamond", "style": "enlarge"})); let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); let codes: Vec = p.objects.iter().filter_map(|o| if let InlineObject::Field { instr, .. } = o { Some(instr.clone()) } else { None }).collect(); assert_eq!(codes, ["eq \\o\\ac(◇,12) \\* enlarge", "eq \\o\\ac(○,字)"]); // Word's structure in the .docx: no result, the symbol and the text in runs of their own // sizes; reading it back gives the same fields, styles included. let bytes = wordcraft_docx::write(&s.doc).unwrap(); let back = wordcraft_docx::read(&bytes).unwrap(); let bp = back.para_at(&Pos::body(0, 0)).unwrap(); let back_codes: Vec<(String, String)> = bp .objects .iter() .filter_map(|o| if let InlineObject::Field { instr, result, .. } = o { Some((instr.clone(), result.clone())) } else { None }) .collect(); assert_eq!(back_codes, [("eq \\o\\ac(◇,12) \\* enlarge".to_string(), "12".to_string()), ("eq \\o\\ac(○,字)".to_string(), "字".to_string())]); // Change the shape of the enclosed characters at the caret, then remove it. let e = s.doc.para_at(&Pos::body(0, 0)).unwrap().text.find('\u{FFFC}').unwrap(); s.sel = crate::Selection::caret(Pos::body(0, e + 3)); run(&mut s, "format.enclose", json!({"shape": "square"})); assert!(field(&s).unwrap().0.starts_with("eq \\o\\ac(□,12) \\* enlarge"), "the style stays"); run(&mut s, "format.enclose", json!({"shape": null})); assert_eq!(text(&s), "No 12 and 字 here"); assert_eq!(s.selected_text(), "12"); run(&mut s, "edit.undo", json!({})); run(&mut s, "edit.undo", json!({})); assert!(field(&s).unwrap().0.starts_with("eq \\o\\ac(◇,12)")); run(&mut s, "edit.undo", json!({})); run(&mut s, "edit.undo", json!({})); assert_eq!(field(&s), None, "back to plain text"); assert!(s.run("format.enclose", &json!({"shape": null})).is_err(), "nothing to remove"); // Text given (the dialog's Text box) goes in at a caret. run(&mut s, "caret.docEnd", json!({})); assert!(s.run("format.enclose", &json!({"shape": "triangle", "text": "ABC"})).is_err()); run(&mut s, "format.enclose", json!({"shape": "triangle", "text": "A"})); assert_eq!(text(&s), "No 12 and 字 hereA"); assert_eq!(field(&s), Some(("eq \\o\\ac(△,A)".to_string(), "A".to_string()))); } #[test] fn list_buttons_follow_the_paragraphs_own_level() { use crate::cmd::para::{ListButtons, list_buttons}; let mut s = s(); run(&mut s, "text.insert", json!({"text": "item"})); let state = |s: &Session| { let rp = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props)); list_buttons(&s.doc, rp.and_then(|r| r.numbering)) }; assert_eq!(state(&s), ListButtons::default(), "plain text: no list button on"); run(&mut s, "para.bullets", json!({})); assert_eq!(state(&s), ListButtons { bullets: true, numbering: false, multilevel: false }); // A bulleted list whose second level is numbered: at level 2 the paragraph is numbered. let num = s.doc.para_at(&s.sel.focus).unwrap().props.numbering.unwrap().num; let numbered = wordcraft_doc::numbering::Level { text: "%2.".into(), ..Default::default() }; s.doc.numbering.nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered)); run(&mut s, "para.listLevel", json!({"level": 1})); assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false }); run(&mut s, "para.numbering", json!({})); assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false }, "1. a. i. is not multilevel"); // Legal numbering (1. 1.1. 1.1.1.) numbers levels together, even on its first level. (Its // first level reads like "1." numbering, which the button would toggle off, so start plain.) run(&mut s, "para.numbering", json!({"off": true})); run(&mut s, "para.multilevel", json!({"kind": "legal"})); run(&mut s, "para.listLevel", json!({"level": 0})); assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: true }); run(&mut s, "para.listLevel", json!({"level": 2})); assert!(state(&s).multilevel); run(&mut s, "para.numbering", json!({"off": true})); assert_eq!(state(&s), ListButtons::default()); } #[test] fn replace_all_splits_paragraphs_at_a_paragraph_mark_in_the_replacement() { // Issue #423: untracked Replace All left `\r` in the paragraph instead of splitting it. for with in ["A\rB", "A\u{2029}B"] { let mut s = s(); run(&mut s, "text.insert", json!({"text": "cat cat"})); run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": with})); assert_eq!(body_texts(&s), ["A", "B A", "B"], "{with:?}"); } // A line feed stays a line break. let mut s = s(); run(&mut s, "text.insert", json!({"text": "cat"})); run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "A\nB"})); assert_eq!(body_texts(&s), ["A\nB"]); } /// A one-row table converted from tab-separated `text`. fn table_from(text: &str) -> Session { let mut s = s(); run(&mut s, "document.setText", json!({"text": text})); run(&mut s, "select.all", json!({})); run(&mut s, "table.fromText", json!({})); s } fn table_texts(s: &Session) -> Vec> { let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table"); t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()).collect()).collect() } #[test] fn convert_text_to_table_refuses_to_drop_content() { // Issue #1318: a 64th field and a table between the selected paragraphs were silently lost. { let wide = (0..64).map(|i| i.to_string()).collect::>().join("\t"); let mut s = s(); run(&mut s, "document.setText", json!({"text": wide})); run(&mut s, "select.all", json!({})); let depth = s.undo_depth(); assert!(s.run("table.fromText", &json!({})).is_err()); assert_eq!(s.doc.body.len(), 1); assert!(s.doc.body.iter().all(|b| b.as_table().is_none())); assert_eq!(s.undo_depth(), depth); } let mut s = s(); run(&mut s, "document.setText", json!({"text": "x\ny\nz"})); let at = |i: u32| json!({"story": "body", "path": [i], "off": 0}); run(&mut s, "select.range", json!({"anchor": at(1), "focus": at(1)})); run(&mut s, "table.fromText", json!({})); assert!(s.doc.body.get(1).and_then(|b| b.as_table()).is_some()); run(&mut s, "select.range", json!({"anchor": at(0), "focus": at(2)})); let depth = s.undo_depth(); assert!(s.run("table.fromText", &json!({})).is_err()); assert_eq!(s.doc.body.len(), 3); assert!(s.doc.body.get(1).and_then(|b| b.as_table()).is_some()); assert_eq!(s.undo_depth(), depth); } #[test] fn delete_columns_deletes_every_selected_column() { // Issue #431: only the selection's focus column went. let cell = |r: u32, c: u32, off: usize| json!({"story": "body", "path": [0, r, c, 0], "off": off}); for (anchor, focus) in [(cell(0, 0, 0), cell(1, 1, 1)), (cell(1, 1, 1), cell(0, 0, 0))] { let mut s = table_from("Left\tMiddle\tRight\nA\tB\tC"); run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus})); run(&mut s, "table.deleteColumn", json!({})); assert_eq!(table_texts(&s), [["Right"], ["C"]]); run(&mut s, "edit.undo", json!({})); assert_eq!(table_texts(&s), [["Left", "Middle", "Right"], ["A", "B", "C"]]); } // A caret deletes its own column only. let mut s = table_from("Left\tMiddle\tRight"); run(&mut s, "caret.set", json!({"pos": cell(0, 1, 0)})); run(&mut s, "table.deleteColumn", json!({})); assert_eq!(table_texts(&s), [["Left", "Right"]]); } #[test] fn split_cells_leaves_the_other_rows_alone() { // Issue #432: splitting a cell inserted a grid column with a new cell in every row. let spans = |s: &Session| -> Vec> { let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table"); t.rows.iter().map(|r| r.cells.iter().map(wordcraft_doc::Cell::span).collect()).collect() }; let mut s = table_from("Left\nA"); let width: f32 = s.doc.body.iter().find_map(|b| b.as_table()).unwrap().total_width(); run(&mut s, "table.split", json!({"columns": 2})); assert_eq!(table_texts(&s), [vec!["Left", ""], vec!["A"]]); assert_eq!(spans(&s), [vec![1, 1], vec![2]]); let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap(); assert!((t.total_width() - width).abs() < 0.1, "{:?}", t.grid); assert!((t.grid[0] - width / 2.0).abs() < 0.1, "{:?}", t.grid); let mut s = table_from("Left\tMiddle\tRight\nA\tB\tC"); run(&mut s, "table.split", json!({"columns": 2})); assert_eq!(table_texts(&s), [vec!["Left", "", "Middle", "Right"], vec!["A", "B", "C"]]); assert_eq!(spans(&s), [vec![1, 1, 1, 1], vec![2, 1, 1]]); } fn table_spans(s: &Session) -> Vec> { let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table"); t.rows.iter().map(|r| r.cells.iter().map(wordcraft_doc::Cell::span).collect()).collect() } fn table_vmerge(s: &Session) -> Vec> { let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table"); t.rows.iter().map(|r| r.cells.iter().map(|c| c.props.vmerge).collect()).collect() } /// #2592: Split Cells into columns and rows. A one-row cell gets new rows under it, the row's /// other cells merging down over them; one undo puts the table back. #[test] fn split_cells_into_columns_and_rows() { use wordcraft_doc::props::VMerge::{Continue, None as No, Restart}; let mut s = table_from("A\tB\nC\tD"); let depth = s.undo_depth(); run(&mut s, "table.split", json!({"columns": 2, "rows": 2})); assert_eq!(s.undo_depth(), depth + 1); assert_eq!(table_texts(&s), [vec!["A", "", "B"], vec!["", "", ""], vec!["C", "D"]]); assert_eq!(table_spans(&s), [vec![1, 1, 1], vec![1, 1, 1], vec![2, 1]]); assert_eq!(table_vmerge(&s), [vec![No, No, Restart], vec![No, No, Continue], vec![No, No]]); run(&mut s, "edit.undo", json!({})); assert_eq!(table_texts(&s), [["A", "B"], ["C", "D"]]); assert_eq!(table_spans(&s), [[1, 1], [1, 1]]); // Rows only. let mut s = table_from("A\tB"); run(&mut s, "table.split", json!({"columns": 1, "rows": 3})); assert_eq!(table_texts(&s), [vec!["A", "B"], vec!["", ""], vec!["", ""]]); assert_eq!(table_vmerge(&s), [[No, Restart], [No, Continue], [No, Continue]]); } /// #2592: Merge cells before split merges the selection, then splits the merged cell; without /// it every selected cell is split on its own. #[test] fn split_cells_merge_before_split() { use wordcraft_doc::props::VMerge::None as No; let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0}); let mut s = table_from("A\tB\nC\tD"); run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)})); let depth = s.undo_depth(); run(&mut s, "table.split", json!({"columns": 2, "rows": 2})); assert_eq!(s.undo_depth(), depth + 1, "merge and split are one step"); assert_eq!(table_spans(&s), [[1, 1], [1, 1]]); assert_eq!(table_vmerge(&s), [[No, No], [No, No]]); assert_eq!(table_texts(&s), [["ABCD", ""], ["", ""]]); let mut s = table_from("A\tB\nC\tD"); run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)})); run(&mut s, "table.split", json!({"columns": 2, "mergeFirst": false})); assert_eq!(table_texts(&s), [["A", "", "B", ""], ["C", "", "D", ""]]); assert_eq!(table_spans(&s), [[1, 1, 1, 1], [1, 1, 1, 1]]); run(&mut s, "edit.undo", json!({})); assert_eq!(table_texts(&s), [["A", "B"], ["C", "D"]]); // As in Word: merged cells split into at most as many rows as were selected, and cells split // one by one keep their rows. let mut s = table_from("A\tB\nC\tD"); run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)})); let depth = s.undo_depth(); assert!(s.run("table.split", &json!({"columns": 1, "rows": 3})).is_err(), "rows 1 to 2 only"); assert!(s.run("table.split", &json!({"columns": 2, "rows": 2, "mergeFirst": false})).is_err()); assert_eq!(s.undo_depth(), depth); assert_eq!(table_texts(&s), [["A", "B"], ["C", "D"]]); run(&mut s, "table.split", json!({"columns": 1, "rows": 2})); assert_eq!(table_texts(&s), [["AC", "B"], ["", "D"]]); } /// #2592: a vertically merged cell splits into rows that divide its height; other counts are /// refused without changing anything. #[test] fn split_cells_of_a_vertically_merged_cell() { use wordcraft_doc::props::VMerge::{Continue, None as No, Restart}; let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0}); let merged = || { let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH"); run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(3, 0)})); run(&mut s, "table.merge", json!({})); s }; let mut s = merged(); assert_eq!(table_vmerge(&s), [[Restart, No], [Continue, No], [Continue, No], [Continue, No]]); run(&mut s, "table.split", json!({"columns": 2, "rows": 2})); assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap().rows.len(), 4, "no new rows"); assert_eq!(table_vmerge(&s), [[Restart, Restart, No], [Continue, Continue, No], [Restart, Restart, No], [Continue, Continue, No]]); assert_eq!(table_spans(&s), [[1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1]]); // The caret in a continued cell splits the whole merged cell. let mut s = merged(); run(&mut s, "caret.set", json!({"pos": cell(2, 0)})); run(&mut s, "table.split", json!({"columns": 1, "rows": 4})); assert_eq!(table_vmerge(&s), [[No, No], [No, No], [No, No], [No, No]]); let mut s = merged(); let (depth, before) = (s.undo_depth(), table_vmerge(&s)); let e = s.run("table.split", &json!({"columns": 1, "rows": 3})).unwrap_err(); assert!(matches!(e, crate::CmdError::Params(_)), "{e:?}"); assert!(e.to_string().contains("The number must be a divisor of 4"), "3 doesn't divide 4: {e}"); assert_eq!((s.undo_depth(), table_vmerge(&s)), (depth, before)); // 1 × 1 on the 4-row cell changes nothing either. let mut s = merged(); run(&mut s, "table.split", json!({"columns": 1, "rows": 1})); assert_eq!(table_vmerge(&s), [[Restart, No], [Continue, No], [Continue, No], [Continue, No]]); } /// #2592 review: cells split one by one split a vertically merged cell once, not once per row it /// covers, and every other selected cell in two. #[test] fn split_cells_one_by_one_with_a_merged_cell() { use wordcraft_doc::props::VMerge::{Continue, None as No, Restart}; let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0}); let merged = || { let mut s = table_from("A\tB\nC\tD"); run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)})); run(&mut s, "table.merge", json!({})); s }; let mut s = merged(); run(&mut s, "table.selectTable", json!({})); run(&mut s, "table.split", json!({"columns": 2, "mergeFirst": false})); assert_eq!(table_spans(&s), [[1, 1, 1, 1], [1, 1, 1, 1]]); assert_eq!(table_texts(&s), [["AC", "", "B", ""], ["", "", "D", ""]]); assert_eq!(table_vmerge(&s), [[Restart, Restart, No, No], [Continue, Continue, No, No]]); // The merged cell alone is one cell. let mut s = merged(); run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)})); run(&mut s, "table.split", json!({"columns": 2, "mergeFirst": false})); assert_eq!(table_spans(&s), [[1, 1, 1], [1, 1, 1]]); assert_eq!(table_vmerge(&s), [[Restart, Restart, No], [Continue, Continue, No]]); } /// #2592: Convert Text to Table separates at paragraphs, commas, tabs or another character, /// takes a column count and an AutoFit behaviour, and undoes in one step. #[test] fn convert_text_to_table_dialog_options() { let convert = |text: &str, params: serde_json::Value| { let mut s = s(); run(&mut s, "document.setText", json!({"text": text})); run(&mut s, "select.all", json!({})); let depth = s.undo_depth(); run(&mut s, "table.fromText", params); assert_eq!(s.undo_depth(), depth + 1); s }; let s1 = convert("a;b;c\nd;e", json!({"separator": "other", "char": ";"})); assert_eq!(table_texts(&s1), [["a", "b", "c"], ["d", "e", ""]]); let s1 = convert("a\nb\nc\nd", json!({"separator": "paragraph"})); assert_eq!(table_texts(&s1), [["a"], ["b"], ["c"], ["d"]]); let s1 = convert("a\nb\nc", json!({"separator": "paragraph", "columns": 2})); assert_eq!(table_texts(&s1), [["a", "b"], ["c", ""]]); // More columns than fields pads; fewer wraps a paragraph's fields onto more rows. let s1 = convert("a\tb\tc\nd", json!({"columns": 4})); assert_eq!(table_texts(&s1), [["a", "b", "c", ""], ["d", "", "", ""]]); let s1 = convert("a\tb\tc\nd", json!({"columns": 2})); assert_eq!(table_texts(&s1), [["a", "b"], ["c", ""], ["d", ""]]); let mut s1 = convert("a,b\nc,d", json!({"separator": "comma", "autofit": "fixed"})); let t = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone(); assert!(t(&s1).props.fixed); run(&mut s1, "edit.undo", json!({})); assert_eq!(body_texts(&s1), ["a,b", "c,d"]); let s1 = convert("a,b\nc,d", json!({"separator": "comma", "autofit": "window"})); assert_eq!((t(&s1).props.fixed, t(&s1).props.width_pct), (false, Some(100.0))); let s1 = convert("a,b\nc,d", json!({"separator": "comma", "autofit": "contents"})); let width = cmd::page::sect(&s1).text_width(); assert!(!t(&s1).props.fixed && t(&s1).total_width() < width / 2.0, "{:?}", t(&s1).grid); // Junk is refused or clamped, never a panic. let mut s1 = s(); run(&mut s1, "document.setText", json!({"text": "a-b"})); run(&mut s1, "select.all", json!({})); assert!(s1.run("table.fromText", &json!({"separator": "other"})).is_err(), "no character"); assert!(s1.run("table.fromText", &json!({"separator": "other", "char": ""})).is_err()); assert!(s1.run("table.fromText", &json!({"separator": "nonsense"})).is_err()); assert!(s1.run("table.fromText", &json!({"autofit": "nonsense"})).is_err()); assert_eq!(body_texts(&s1), ["a-b"]); run(&mut s1, "table.fromText", json!({"separator": "other", "char": "-", "columns": 1_000_000_000_u64})); assert_eq!(table_texts(&s1).first().map(Vec::len), Some(63)); } /// #2592, as Word does: Initial column width Auto gives equal columns across the text width; /// a width gives every column that preferred width (kept even when the table is drawn narrower /// to fit the text width), with no table width; both leave the table resizing to its contents. #[test] fn convert_text_to_table_initial_column_width() { let convert = |params: serde_json::Value| { let mut s = s(); run(&mut s, "document.setText", json!({"text": "a\tb\tc\nd\te\tf"})); run(&mut s, "select.all", json!({})); let r = s.run("table.fromText", ¶ms); (s, r) }; let t = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone(); let (s1, _) = convert(json!({"autofit": "initial"})); let width = cmd::page::sect(&s1).text_width(); let tb = t(&s1); assert!(!tb.props.fixed && tb.props.width.is_none()); assert!(tb.grid.iter().all(|&w| (w - width / 3.0).abs() < 0.1), "{:?}", tb.grid); let (s0, _) = convert(json!({"autofit": "initial", "width": 0})); assert_eq!(t(&s0).grid, tb.grid, "0 is Auto"); let (s1, _) = convert(json!({"autofit": "initial", "width": 72})); let tb = t(&s1); assert_eq!(tb.grid, [72.0, 72.0, 72.0]); assert!(tb.rows.iter().flat_map(|r| &r.cells).all(|c| c.props.width == Some(72.0))); assert!(!tb.props.fixed && tb.props.width.is_none()); // 5 in columns are stored as such but drawn within the text width. let (mut s1, _) = convert(json!({"width": 360})); assert_eq!(t(&s1).grid, [360.0, 360.0, 360.0]); let drawn: f32 = s1.layout().pages[0] .items .iter() .filter_map(|it| match it { wordcraft_layout::Placed::Cell { rect, row: 0, .. } => Some(rect.w), _ => None, }) .sum(); assert!(drawn > 0.0 && drawn <= width + 0.5, "{drawn} vs {width}"); // A width is the Initial column width only: AutoFit to contents or the window ignores it. for mode in ["contents", "window"] { let (with, _) = convert(json!({"autofit": mode, "width": 72})); let (without, _) = convert(json!({ "autofit": mode })); let cell_widths = |s: &Session| t(s).rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.width)).collect::>(); assert_eq!((t(&with).grid, cell_widths(&with)), (t(&without).grid, cell_widths(&without)), "{mode}"); } // More than 22 in, or less than nothing, is refused without converting. for bad in [json!(1585), json!(-1), json!("wide")] { let (s1, r) = convert(json!({ "width": bad })); assert!(r.is_err(), "{bad}"); assert!(s1.doc.body.iter().all(|b| b.as_table().is_none())); } } /// #2592, as Word does: a vertically merged cell splits into 1 to its height rows; its height /// cuts it into one cell per row, 1 × 1 changes nothing, a number that doesn't divide the height or more than it is refused with Word's messages and /// nothing changes, also when neighbours are merged with it first. #[test] fn split_cells_of_a_merged_cell_follow_word() { use wordcraft_doc::props::VMerge::{Continue, None as No, Restart}; let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0}); let merged = || { let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH"); run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)})); run(&mut s, "table.merge", json!({})); s }; for params in [json!({"columns": 1, "rows": 3}), json!({"columns": 1, "rows": 4}), json!({"columns": 2, "rows": 3})] { let mut s = merged(); let (depth, before) = (s.undo_depth(), table_vmerge(&s)); let e = s.run("table.split", ¶ms).unwrap_err().to_string(); assert!(e.contains("The number must be between 1 and 2"), "{e}"); assert_eq!((s.undo_depth(), table_vmerge(&s)), (depth, before)); } // 1 × 1 on a merged cell is a no-op (Word, 2 to 5 rows). let mut s = merged(); let before = table_vmerge(&s); run(&mut s, "table.split", json!({"columns": 1, "rows": 1})); assert_eq!(table_vmerge(&s), before); { let params = json!({"columns": 1, "rows": 2}); let mut s = merged(); run(&mut s, "table.split", params.clone()); assert_eq!(table_vmerge(&s), [[No, No], [No, No], [No, No], [No, No]], "{params}"); assert_eq!(table_texts(&s)[0][0], "AC"); assert_eq!(table_texts(&s)[1][0], ""); } // A 3-row cell: 2 rows is not a divisor (Word's alert, nothing changes); 1 × 1 is a no-op; 3 rows gives one cell per row. let tall = || { let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH"); run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(2, 0)})); run(&mut s, "table.merge", json!({})); s }; for params in [json!({"columns": 1, "rows": 2}), json!({"columns": 2, "rows": 2})] { let mut s = tall(); let (depth, before) = (s.undo_depth(), table_vmerge(&s)); let e = s.run("table.split", ¶ms).unwrap_err(); assert!(matches!(e, crate::CmdError::Params(_)), "{e:?}"); assert!(e.to_string().contains("The number must be a divisor of 3"), "{e}"); assert_eq!((s.undo_depth(), table_vmerge(&s)), (depth, before)); } let mut s = tall(); run(&mut s, "table.split", json!({"columns": 1, "rows": 1})); assert_eq!(table_vmerge(&s), [[Restart, No], [Continue, No], [Continue, No], [No, No]]); assert_eq!(table_texts(&s)[0][0], "ACE"); let mut s = tall(); run(&mut s, "table.split", json!({"columns": 1, "rows": 3})); assert_eq!(table_vmerge(&s), [[No, No], [No, No], [No, No], [No, No]]); // Merge cells before split, then 1 × 1: one cell, as before (merging through Split Cells). let mut s = table_from("A\tB\nC\tD"); run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)})); run(&mut s, "table.split", json!({"columns": 1, "rows": 1})); assert_eq!(table_spans(&s), [[2], [2]]); assert_eq!(table_vmerge(&s), [[wordcraft_doc::props::VMerge::Restart], [wordcraft_doc::props::VMerge::Continue]]); // The merged cell and its neighbours, merged first: the limit is the selection's height. let mut s = merged(); run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)})); let e = s.run("table.split", &json!({"columns": 1, "rows": 3})).unwrap_err().to_string(); assert!(e.contains("The number must be between 1 and 2"), "{e}"); } /// #2592, as Word does: a cell merged across columns splits into equal parts inside its old /// width; the other rows' cells keep their widths. #[test] fn split_cells_across_columns_into_equal_parts() { let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0}); let widths = |s: &Session| -> Vec> { let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap(); (0..t.rows.len()) .map(|r| { let row = &t.rows[r]; (0..row.cells.len()).map(|c| (0..row.cells[c].span()).map(|k| t.grid[t.grid_col(r, c) + k]).sum()).collect() }) .collect() }; for (across, parts) in [(2, 3), (3, 2), (2, 2)] { let mut s = table_from("A\tB\tC\nD\tE\tF"); // Unequal columns, as in a real document. s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![0])).unwrap().grid = vec![100.0, 50.0, 150.0]; run(&mut s, "select.range", json!({"anchor": cell(1, 0), "focus": cell(1, across - 1)})); run(&mut s, "table.merge", json!({})); let (top, old) = (widths(&s)[0].clone(), widths(&s)[1][0]); run(&mut s, "table.split", json!({"columns": parts, "rows": 1})); let w = widths(&s); let new: Vec = w[1].iter().take(parts as usize).copied().collect(); assert!(new.iter().all(|x| (x - old / parts as f32).abs() < 0.1), "{across}->{parts}: {w:?}"); let mut top_lines: Vec = Vec::new(); let mut x = 0.0; for v in &w[0] { x += v; top_lines.push(x); } let mut expect = Vec::new(); let mut x = 0.0; for v in &top { x += v; expect.push(x); } assert_eq!(top_lines.len(), expect.len(), "{across}->{parts}: {w:?}"); assert!(top_lines.iter().zip(&expect).all(|(a, b)| (a - b).abs() < 0.1), "{across}->{parts}: {w:?}"); } } /// #385: note options for the whole document or one section, and converting notes, which undoes /// in one step. #[test] fn note_options_per_section_and_convert_notes() { use wordcraft_doc::PartKind; use wordcraft_doc::para::{InlineObject, NoteKind}; let mut s = s(); run(&mut s, "text.insert", json!({"text": "One"})); let a = run(&mut s, "references.footnote", json!({"text": "First", "mark": "*"}))["id"].as_u64().unwrap() as u32; s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); run(&mut s, "layout.break", json!({"kind": "nextPage"})); run(&mut s, "text.insert", json!({"text": "Two"})); let b = run(&mut s, "references.footnote", json!({"text": "Second"}))["id"].as_u64().unwrap() as u32; s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); // The second section restarts at 3 in letters; the first keeps the document's options. let got = run(&mut s, "references.noteOptions", json!({"numFmt": "lowerLetter", "numStart": 3, "numRestart": "eachSect", "scope": "section"})); assert_eq!((got["footnote"]["numFmt"].as_str(), got["footnote"]["numStart"].as_u64()), (Some("lowerLetter"), Some(3))); let sections = s.doc.sections(); assert!(sections[0].1.footnote_pr.is_empty()); assert_eq!(sections[1].1.footnote_pr.num_start, Some(3)); // Hostile or wrong values are refused or clamped. assert!(s.run("references.noteOptions", &json!({"kind": "endnote", "pos": "pageBottom"})).is_err()); assert!(s.run("references.noteOptions", &json!({"kind": "endnote", "numRestart": "eachPage"})).is_err()); let got = run(&mut s, "references.noteOptions", json!({"kind": "endnote", "pos": "sectEnd", "numStart": -5})); assert_eq!((got["endnote"]["pos"].as_str(), got["endnote"]["numStart"].as_u64()), (Some("sectEnd"), Some(1))); // The whole document: the section's own start gives way. run(&mut s, "references.noteOptions", json!({"numStart": 2})); assert_eq!(s.doc.sections()[1].1.footnote_pr.num_start, None); assert_eq!(s.doc.settings.footnote_pr.num_start, Some(2)); // Convert footnotes to endnotes: kinds, marks and styles; the custom mark stays. One undo. let kinds = |s: &Session| -> Vec { let mut v = Vec::new(); s.doc.objects_in_reading_order(&mut |o| { if let InlineObject::NoteRef { kind, .. } = o { v.push(*kind); } }); v }; assert_eq!(run(&mut s, "references.convertNotes", json!({"to": "endnote"}))["converted"], 2); assert_eq!(kinds(&s), vec![NoteKind::Endnote, NoteKind::Endnote]); assert!([a, b].iter().all(|id| s.doc.parts[id].kind == PartKind::Endnote)); let note = s.doc.para(StoryRef::Part(a), &wordcraft_doc::Path::top(0)).unwrap(); assert_eq!(note.props.style.as_deref(), Some("EndnoteText")); assert!(matches!(¬e.objects[0], InlineObject::NoteRef { kind: NoteKind::Endnote, custom, .. } if custom == "*")); run(&mut s, "edit.undo", json!({})); assert_eq!(kinds(&s), vec![NoteKind::Footnote, NoteKind::Footnote]); assert_eq!(s.doc.parts[&b].kind, PartKind::Footnote); assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err()); } #[test] fn next_footnote_skips_endnotes() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "A"})); run(&mut s, "references.footnote", json!({"text": "Fn1"})); s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); run(&mut s, "text.insert", json!({"text": "B"})); run(&mut s, "references.endnote", json!({"text": "En1"})); s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); run(&mut s, "text.insert", json!({"text": "C"})); run(&mut s, "references.footnote", json!({"text": "Fn2"})); s.sel = crate::Selection::caret(Pos::body(0, 0)); run(&mut s, "references.nextFootnote", json!({})); assert_eq!(s.sel.focus.off, 1); run(&mut s, "references.nextFootnote", json!({})); assert_eq!(s.sel.focus.off, 9); } /// A tiny synthetic Polish Hunspell dictionary (written for these tests). Real dictionaries are /// installed on the system, never bundled; every Polish test installs this same one. fn polish_test_dictionary() { wordcraft_proof::pl::install_dictionary(POLISH_TEST_AFF, POLISH_TEST_DIC).unwrap(); } const POLISH_TEST_AFF: &str = "SET UTF-8\nTRY aioeznrwcyszżtkpdmłjlubąęgh\nMAP 2\nMAP oó\nMAP zż\nREP 2\nREP ż rz\nREP rz ż\n"; const POLISH_TEST_DIC: &str = "12\npłynie\nrzeka\na\nto\nwiem\nże\ntak\nw\nogóle\nzażółć\ngęślą\njaźń\n"; /// A tiny synthetic Portuguese Hunspell dictionary (written for these tests). Real dictionaries /// are installed on the system, never bundled; every Portuguese test installs this same one. fn portuguese_test_dictionary() { wordcraft_proof::pt::install_dictionary(PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC).unwrap(); } const PORTUGUESE_TEST_AFF: &str = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD F\nTRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêô\nMAP 2\nMAP aáã\nMAP eé\nREP 1\nREP seje seja\n"; const PORTUGUESE_TEST_DIC: &str = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\né\nque\nseja\nfrequencia/F\nmuito\n"; /// #1063: File › Options › Proofing › Dictionaries folder. A folder of Hunspell files is saved, /// searched first and reported per language; a missing folder or a relative path is refused and /// changes nothing. The folder holds the same test dictionaries the other tests install, so tests /// running meanwhile see the same words; it stays set for the rest of the run for that reason. #[test] fn dictionaries_folder_option_is_saved_searched_and_reported() { let dir = std::env::temp_dir().join(format!("wc-engine-dictionaries-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); for (name, aff, dic) in [ ("pl_PL", POLISH_TEST_AFF, POLISH_TEST_DIC), ("pt_BR", PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC), ("id_ID", INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC), ] { std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap(); std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap(); } let mut s = s(); let missing = dir.join("no-such-folder"); assert!(s.run("file.options", &json!({"dictionariesFolder": missing.to_string_lossy()})).is_err()); assert!(s.run("file.options", &json!({"dictionariesFolder": "relative/dictionaries"})).is_err()); assert!(s.run("file.options", &json!({"dictionariesFolder": 7})).is_err()); assert_eq!(s.prefs.dictionaries_folder, None, "a refused folder changes nothing"); let r = run(&mut s, "file.options", json!({"dictionariesFolder": dir.to_string_lossy()})); assert_eq!(s.prefs.dictionaries_folder.as_deref(), Some(dir.to_string_lossy().as_ref())); assert_eq!(r["dictionariesFolder"], json!(dir.to_string_lossy())); let dicts = r["dictionaries"].as_array().unwrap(); let file = |lang: &str| dicts.iter().find(|d| d["language"] == lang).map(|d| d["file"].clone()); assert_eq!(file("pl"), Some(json!(dir.join("pl_PL.dic").to_string_lossy()))); assert_eq!(file("pt"), Some(json!(dir.join("pt_BR.dic").to_string_lossy()))); assert_eq!(file("id"), Some(json!(dir.join("id_ID.dic").to_string_lossy()))); // Polish text is checked against the folder's dictionary. run(&mut s, "document.setText", json!({"text": "rzeka płynie żeka"})); run(&mut s, "select.all", json!({})); run(&mut s, "review.language", json!({"lang": "pl-PL"})); let issues = run(&mut s, "review.issues", json!({})); let words: Vec<&str> = issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str()).collect(); assert_eq!(words, ["żeka"], "{issues}"); // Saved with the settings. let prefs = serde_json::to_value(&s.prefs).unwrap(); assert_eq!(prefs["dictionariesFolder"], json!(dir.to_string_lossy())); } /// Review › Language and the proofing commands work in Portuguese end to end: the language makes /// the document's words Portuguese, F7 and the issue list use the Portuguese dictionary and /// grammar, suggestions come from it, and a suggestion fixes the text. #[test] fn portuguese_proofing_through_the_review_commands() { portuguese_test_dictionary(); let mut s = s(); run(&mut s, "document.setText", json!({"text": "Saudade coraçao, e ele ele disse."})); // English by default: the Portuguese words are English misspellings. assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 5); run(&mut s, "select.all", json!({})); run(&mut s, "review.language", json!({"lang": "pt_br", "default": true})); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pt-BR"), "the tag is normalized"); assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pt-BR"); let issues = run(&mut s, "review.issues", json!({})); let arr = issues.as_array().unwrap(); let has = |text: &str, kind: &str, sugg: &str| { arr.iter() .any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "pt" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) }; assert!(has("coraçao", "spelling", "coração"), "{issues}"); assert!(has("ele ele", "grammar", "ele"), "{issues}"); assert_eq!(arr.len(), 2, "{issues}"); assert!(arr.iter().any(|i| i["message"] == "Palavra repetida: \"ele\""), "{issues}"); // F7 from the start selects the first issue; Change applies a suggestion. run(&mut s, "caret.docStart", json!({})); let first = run(&mut s, "review.spelling", json!({})); assert_eq!(first["text"], "coraçao"); run(&mut s, "review.applySuggestion", json!({"text": "coração"})); assert!(text(&s).starts_with("Saudade coração,"), "{}", text(&s)); // Add to Dictionary works for Portuguese words too. run(&mut s, "review.addToDictionary", json!({"word": "coraçao"})); let left = run(&mut s, "review.issues", json!({})); assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); } fn indonesian_test_dictionary() { wordcraft_proof::id::install_dictionary(INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC).unwrap(); } // Hand-written synthetic fixture (no system dictionary data). const INDONESIAN_TEST_AFF: &str = "SET UTF-8\nFLAG UTF-8\nTRY aniekrumgtslhbdopy\nSFX A Y 1\nSFX A 0 an .\n"; const INDONESIAN_TEST_DIC: &str = "8\nrumah\nsekolah\nanak\nbermain\ndi\nmakan/A\nsaya\nkami\n"; /// Indonesian proofing (#498) through the same commands as Review, F7, Change and Add to /// Dictionary. #[test] fn indonesian_proofing_through_the_review_commands() { indonesian_test_dictionary(); let mut s = s(); run(&mut s, "document.setText", json!({"text": "Saya di sekolha, anak anak bermain."})); run(&mut s, "select.all", json!({})); run(&mut s, "review.language", json!({"lang": "id_id", "default": true})); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("id-ID")); assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "id-ID"); let issues = run(&mut s, "review.issues", json!({})); let arr = issues.as_array().unwrap(); assert_eq!(arr.len(), 2, "{issues}"); assert!(arr.iter().all(|i| i["lang"] == "id"), "{issues}"); assert!( arr.iter().any(|i| i["text"] == "sekolha" && i["kind"] == "spelling" && i["suggestions"].as_array().unwrap().iter().any(|s| s == "sekolah")), "{issues}" ); assert!(arr.iter().any(|i| i["text"] == "anak anak" && i["kind"] == "grammar" && i["suggestions"][0] == "anak-anak"), "{issues}"); run(&mut s, "caret.docStart", json!({})); assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "sekolha"); run(&mut s, "review.applySuggestion", json!({"text": "sekolah"})); assert!(text(&s).starts_with("Saya di sekolah,"), "{}", text(&s)); run(&mut s, "select.text", json!({"text": "sekolah"})); run(&mut s, "text.insert", json!({"text": "zqkataidengine"})); assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 2); run(&mut s, "review.addToDictionary", json!({"word": "zqkataidengine"})); let left = run(&mut s, "review.issues", json!({})); let left = left.as_array().unwrap(); assert_eq!(left.len(), 1); assert_eq!(left[0]["kind"], "grammar"); assert_eq!(left[0]["lang"], "id"); } /// Czech proofing through the same commands as Review, F7, Change and Add to Dictionary. #[test] fn czech_proofing_through_the_review_commands() { czech_test_dict::install(); let mut s = s(); run(&mut s, "document.setText", json!({"text": "Město pocitac, škola škola."})); assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 3); run(&mut s, "select.all", json!({})); run(&mut s, "review.language", json!({"lang": "cs_cz", "default": true})); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("cs-CZ")); assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "cs-CZ"); let issues = run(&mut s, "review.issues", json!({})); let arr = issues.as_array().unwrap(); assert_eq!(arr.len(), 2, "{issues}"); assert!(arr.iter().all(|i| i["lang"] == "cs"), "{issues}"); assert!( arr.iter().any(|i| i["text"] == "pocitac" && i["kind"] == "spelling" && i["suggestions"].as_array().unwrap().iter().any(|s| s == "počítač")), "{issues}" ); assert!(arr.iter().any(|i| i["text"] == "škola škola" && i["kind"] == "grammar" && i["message"] == "Opakované slovo: „škola“"), "{issues}"); run(&mut s, "caret.docStart", json!({})); assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "pocitac"); run(&mut s, "review.applySuggestion", json!({"text": "počítač"})); assert!(text(&s).starts_with("Město počítač,")); // Exercise dictionary addition on a misspelling still present in the document. run(&mut s, "select.text", json!({"text": "počítač"})); run(&mut s, "text.insert", json!({"text": "zqcsenginevlastni"})); assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 2); run(&mut s, "review.addToDictionary", json!({"word": "zqcsenginevlastni"})); let left = run(&mut s, "review.issues", json!({})); let left = left.as_array().unwrap(); assert_eq!(left.len(), 1); assert_eq!(left[0]["kind"], "grammar"); assert_eq!(left[0]["lang"], "cs"); } /// Review › Language and the proofing commands work in Polish end to end: the language makes /// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar, /// suggestions come from it, and a suggestion fixes the text. fn italian_test_dictionary() { let aff = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n"; let dic = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n"; wordcraft_proof::it::install_dictionary(aff, dic).unwrap(); } /// Review › Language and the proofing commands work in Italian end to end: the language makes /// the document's words Italian, F7 and the issue list use the Italian dictionary and grammar, /// suggestions come from it, and a suggestion fixes the text. #[test] fn italian_proofing_through_the_review_commands() { italian_test_dictionary(); let mut s = s(); run(&mut s, "document.setText", json!({"text": "Casa caffe, e roma roma disse."})); // English by default: the Italian words are English misspellings. assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 3); run(&mut s, "select.all", json!({})); run(&mut s, "review.language", json!({"lang": "it_IT", "default": true})); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("it-IT"), "the tag is normalized"); assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "it-IT"); let issues = run(&mut s, "review.issues", json!({})); let arr = issues.as_array().unwrap(); let has = |text: &str, kind: &str, sugg: &str| { arr.iter() .any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "it" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) }; assert!(has("caffe", "spelling", "caffè"), "{issues}"); assert!(has("roma roma", "grammar", "roma"), "{issues}"); assert_eq!(arr.len(), 2, "{issues}"); assert!(arr.iter().any(|i| i["message"] == "Parola ripetuta: \"roma\""), "{issues}"); // F7 from the start selects the first issue; Change applies a suggestion. run(&mut s, "caret.docStart", json!({})); let first = run(&mut s, "review.spelling", json!({})); assert_eq!(first["text"], "caffe"); run(&mut s, "review.applySuggestion", json!({"text": "caffè"})); assert!(text(&s).starts_with("Casa caffè,"), "{}", text(&s)); // Add to Dictionary works for Italian words too. run(&mut s, "review.addToDictionary", json!({"word": "caffe"})); let left = run(&mut s, "review.issues", json!({})); assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); } #[test] fn polish_proofing_through_the_review_commands() { polish_test_dictionary(); let mut s = s(); run(&mut s, "document.setText", json!({"text": "Płynie żeka, a wogóle to wiem że tak."})); // English by default: the Polish words are English misspellings. assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 5); run(&mut s, "select.all", json!({})); run(&mut s, "review.language", json!({"lang": "pl_PL", "default": true})); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the tag is normalized"); assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pl-PL"); let issues = run(&mut s, "review.issues", json!({})); let arr = issues.as_array().unwrap(); let has = |text: &str, kind: &str, sugg: &str| { arr.iter() .any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "pl" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) }; assert!(has("żeka", "spelling", "rzeka"), "{issues}"); assert!(has("wogóle", "spelling", "w ogóle"), "{issues}"); assert!(has(" że", "grammar", ", że"), "{issues}"); assert_eq!(arr.len(), 3, "{issues}"); assert!(arr.iter().any(|i| i["message"] == "Brak przecinka przed „że”"), "{issues}"); // F7 from the start selects the first issue; Change applies a suggestion. run(&mut s, "caret.docStart", json!({})); let first = run(&mut s, "review.spelling", json!({})); assert_eq!(first["text"], "żeka"); run(&mut s, "review.applySuggestion", json!({"text": "rzeka"})); assert!(text(&s).starts_with("Płynie rzeka,"), "{}", text(&s)); // Add to Dictionary works for Polish words too. run(&mut s, "review.addToDictionary", json!({"word": "wogóle"})); let left = run(&mut s, "review.issues", json!({})); assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); } /// Review › Language and proofing commands work in Turkish end to end. #[test] fn turkish_proofing_through_the_review_commands() { // Spelling and suggestions come from the installed Turkish Hunspell dictionary; machines // without one (CI, most desktops) have nothing to check here. if !crate::proof::tr::has_dictionary() { return; } let mut s = s(); run(&mut s, "document.setText", json!({"text": "Bana bazen gelenler oldu ise de. Sana olacaklardan sonra sorna"})); run(&mut s, "select.all", json!({})); run(&mut s, "review.language", json!({"lang": "tr-TR", "default": true})); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("tr-TR")); let issues = run(&mut s, "review.issues", json!({})); let arr = issues.as_array().unwrap(); // "sorna" must be flagged as spelling issue let sorna_issue = arr.iter().find(|i| i["text"] == "sorna" && i["kind"] == "spelling"); assert!(sorna_issue.is_some(), "sorna must be flagged: {issues}"); // Check suggestions for sorna let sugs: Vec = sorna_issue.unwrap()["suggestions"].as_array().unwrap().iter().filter_map(|x| x.as_str().map(str::to_string)).collect(); assert!(sugs.contains(&"sonra".to_string()), "sorna must suggest sonra: {sugs:?}"); // Add to Dictionary: adding "sorna" should remove its issue run(&mut s, "review.addToDictionary", json!({"word": "sorna"})); let issues_after = run(&mut s, "review.issues", json!({})); let arr_after = issues_after.as_array().unwrap(); assert!(!arr_after.iter().any(|i| i["text"] == "sorna"), "sorna should no longer be flagged after adding to dictionary: {issues_after}"); } /// `review.language`: tags are validated and normalized, `noProof` alone keeps the language, /// no parameters keep the old meaning (US English), and `default` needs a language. #[test] fn review_language_validates_tags_and_marks_text_unchecked() { polish_test_dictionary(); let mut s = s(); run(&mut s, "document.setText", json!({"text": "Hello wrold"})); run(&mut s, "select.all", json!({})); assert!(s.run("review.language", &json!({"lang": "not a tag!"})).is_err()); assert!(s.run("review.language", &json!({"lang": ""})).is_err()); assert!(s.run("review.language", &json!({"default": true, "noProof": true})).is_err(), "default needs a language"); assert_eq!(text(&s), "Hello wrold", "a refused command changes nothing"); run(&mut s, "review.language", json!({"noProof": true})); let st = run(&mut s, "format.state", json!({})); assert_eq!((st["noProof"].clone(), st["lang"].clone()), (json!(true), json!("en-US"))); assert_eq!(run(&mut s, "review.issues", json!({})), json!([])); run(&mut s, "review.language", json!({"noProof": false})); assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 1); run(&mut s, "review.language", json!({"lang": "PL"})); assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pl"); run(&mut s, "review.language", json!({})); assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "en-US"); // A language without proofing tools: no marks. run(&mut s, "review.language", json!({"lang": "fr-FR"})); assert_eq!(run(&mut s, "review.issues", json!({})), json!([])); } /// The editing language (set by the front end from the interface language): new blank /// documents and opened plain text are written in it; the untouched first document switches, /// a document with changes never does, and templates stay English. #[test] fn new_documents_follow_the_editing_language() { polish_test_dictionary(); let mut s = s(); s.set_editing_language(Some("pl-PL")); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the untouched blank document switches"); assert!(!s.dirty && !s.can_undo(), "no change to save or undo"); run(&mut s, "text.insert", json!({"text": "Zażółć gęślą jaźń."})); assert_eq!(run(&mut s, "review.issues", json!({})), json!([])); s.set_editing_language(None); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "a document with changes keeps its language"); s.set_editing_language(Some("pl")); run(&mut s, "file.new", json!({})); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl")); run(&mut s, "file.new", json!({"template": "letter"})); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-US"), "templates are English text"); let data = crate::cmd::insert::base64_encode("Płynie rzeka.".as_bytes()); run(&mut s, "file.open", json!({"path": "notatka.txt", "data": data})); assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl")); assert_eq!(run(&mut s, "review.issues", json!({})), json!([])); // Malformed tags fall back to US English. s.set_editing_language(Some("not a tag")); assert_eq!(s.editing_lang, None); } /// A Polish document keeps its languages through .docx: the default (`w:docDefaults`) and an /// English run (`w:lang`) come back, and so does the proofing. #[test] fn proofing_languages_survive_a_docx_round_trip() { polish_test_dictionary(); let mut s = s(); run(&mut s, "document.setText", json!({"text": "Płynie żeka. Hello wrold."})); run(&mut s, "select.all", json!({})); run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); run(&mut s, "select.text", json!({"text": "Hello wrold."})); run(&mut s, "review.language", json!({"lang": "en-GB"})); let bytes = crate::io::save_bytes("polski.docx", &s.doc).unwrap(); let doc = crate::io::open_bytes("polski.docx", &bytes).unwrap(); assert_eq!(doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); let mut back = Session::new(doc); let issues = run(&mut back, "review.issues", json!({})); let found: Vec<(String, String)> = issues.as_array().unwrap().iter().map(|i| (i["text"].as_str().unwrap().to_string(), i["lang"].as_str().unwrap().to_string())).collect(); assert_eq!(found, [("żeka".to_string(), "pl".to_string()), ("wrold".to_string(), "en".to_string())], "{issues}"); } #[test] fn hide_white_space_toggles_view_state_only() { let mut s = s(); let before = s.doc.body.len(); assert!(!s.view.hide_white_space); assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({}))["value"], true); assert!(s.view.hide_white_space); assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({"value": false}))["value"], false); assert_eq!(s.doc.body.len(), before, "a view option never edits the document"); } /// The first SmartArt (or other graphic) in the body, selected. fn select_graphic(s: &mut Session) { let (path, off) = (0..s.doc.body.len() as u32) .find_map(|b| { let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![b]))?; let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(wordcraft_doc::InlineObject::Graphic { .. })))?; Some((wordcraft_doc::Path(vec![b]), off)) }) .expect("a graphic"); let at = Pos { story: StoryRef::Body, path, off }; let end = Pos { off: off + wordcraft_doc::para::OBJ.len_utf8(), ..at.clone() }; s.sel = crate::Selection { anchor: at, focus: end }; } /// #497: Insert › SmartArt saves as diagram parts (data, layout, style, colours, drawing) and /// reopens as the same editable graphic; an edited data part stays read-only. #[test] fn smart_art_round_trips_through_docx() { use std::io::Read; let mut s = s(); let r = run( &mut s, "insert.smartArt", json!({"layout": "hierarchy", "items": ["Root", "\tLeft", {"text": "Right", "level": 1}], "colors": "colorful"}), ); assert_eq!(r["smartArt"]["layout"], "hierarchy"); let spec = cmd::smart_art::selected_smart_art(&s).expect("selected after insert"); let bytes = crate::io::save_bytes("sa.docx", &s.doc).unwrap(); let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); for part in ["data1", "layout1", "quickStyle1", "colors1", "drawing1"] { assert!(zip.by_name(&format!("word/diagrams/{part}.xml")).is_ok(), "{part} written"); } let mut ct = String::new(); zip.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap(); assert!(ct.contains("diagramDrawing+xml") && ct.contains("diagramData+xml"), "{ct}"); let mut s2 = Session::new(crate::io::open_bytes("sa.docx", &bytes).unwrap()); select_graphic(&mut s2); assert_eq!(cmd::smart_art::selected_smart_art(&s2).as_deref(), Some(&*spec), "reopens editable"); run(&mut s2, "smartArt.layout", json!({"layout": "venn"})); // Saved again, and once more after reopening: still the same graphic. let again = crate::io::save_bytes("sa.docx", &s2.doc).unwrap(); let mut s3 = Session::new(crate::io::open_bytes("sa.docx", &again).unwrap()); select_graphic(&mut s3); assert_eq!(cmd::smart_art::selected_smart_art(&s3).map(|g| g.layout), Some(wordcraft_doc::smart_art::SmartArtLayout::Venn)); // A data part another program rewrote: shown, not editable. let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&again)).unwrap(); let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for i in 0..zip.len() { let mut f = zip.by_index(i).unwrap(); let name = f.name().to_string(); let mut buf = Vec::new(); f.read_to_end(&mut buf).unwrap(); if name.ends_with("data1.xml") { buf = String::from_utf8(buf).unwrap().replace("", "").into_bytes(); } out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); std::io::Write::write_all(&mut out, &buf).unwrap(); } let foreign = out.finish().unwrap().into_inner(); let mut s4 = Session::new(crate::io::open_bytes("sa.docx", &foreign).unwrap()); select_graphic(&mut s4); assert!(cmd::smart_art::selected_smart_art(&s4).is_none()); assert!(s4.run("smartArt.layout", &json!({"layout": "cycle"})).is_err()); } /// #497: item, layout and colour edits are one undo step each; promote and demote move an item /// with the items under it. #[test] fn smart_art_edits_undo_one_step_each() { let mut s = s(); run(&mut s, "insert.smartArt", json!({"layout": "process"})); let items = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| (i.text.clone(), i.level)).collect::>(); let before = items(&s); run(&mut s, "smartArt.items", json!({"items": ["One", "Two", "\tTwo a"]})); assert_eq!(items(&s), [("One".to_string(), 0), ("Two".to_string(), 0), ("Two a".to_string(), 1)]); run(&mut s, "smartArt.demote", json!({"index": 1})); assert_eq!(items(&s)[1..], [("Two".to_string(), 1), ("Two a".to_string(), 2)]); run(&mut s, "smartArt.promote", json!({"index": 1})); run(&mut s, "smartArt.addShape", json!({"after": 0, "text": "New"})); assert_eq!(items(&s)[1], ("New".to_string(), 0)); run(&mut s, "smartArt.colors", json!({"variant": "accent4"})); run(&mut s, "smartArt.layout", json!({"layout": "pyramid"})); run(&mut s, "edit.undo", json!({})); assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap().layout, wordcraft_doc::smart_art::SmartArtLayout::Process); for _ in 0..5 { run(&mut s, "edit.undo", json!({})); } assert_eq!(items(&s), before); assert!(s.run("smartArt.promote", &json!({"index": 0})).is_err(), "already at the top"); run(&mut s, "picture.size", json!({"width": 200, "height": 120, "lockAspect": false})); } /// #497: hostile SmartArt params are capped or refused, never a panic. #[test] fn smart_art_hostile_params_are_capped() { use wordcraft_doc::smart_art::{MAX_ITEMS, MAX_LEVEL, MAX_TEXT}; let mut s = s(); let many: Vec = (0..MAX_ITEMS * 3).map(|i| json!({"text": "x".repeat(10_000), "level": i * 1000})).collect(); run(&mut s, "insert.smartArt", json!({"layout": "hierarchy", "items": many, "width": 1e30, "height": -5})); let g = cmd::smart_art::selected_smart_art(&s).unwrap(); assert_eq!(g.items.len(), MAX_ITEMS); assert!(g.items.iter().all(|i| i.text.chars().count() <= MAX_TEXT && i.level <= MAX_LEVEL)); for bad in [json!({"items": []}), json!({"items": "x"}), json!({"items": [5]}), json!({"layout": "spiral"}), json!({"layout": 3})] { assert!(s.run("insert.smartArt", &bad).is_err(), "{bad}"); } let junk = [json!(null), json!({}), json!({"index": -1, "after": 1e300, "items": [{"level": "deep"}], "variant": [], "layout": {}})]; for id in [ "smartArt.items", "smartArt.addShape", "smartArt.promote", "smartArt.demote", "smartArt.layout", "smartArt.colors", "smartArt.reset", "smartArt.textPane", ] { for j in &junk { let _ = s.run(id, j); } } for _ in 0..MAX_ITEMS { let _ = s.run("smartArt.addShape", &json!({})); } assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap().items.len(), MAX_ITEMS); let _ = s.layout(); } /// Outline view of: Heading 1 "One", body "a", Heading 2 "Two", body "b", Heading 1 "Three", body "c". fn outline_doc() -> Session { let mut s = s(); run(&mut s, "document.setText", json!({"text": "One\na\nTwo\nb\nThree\nc"})); for (i, style) in [(0, "Heading1"), (2, "Heading2"), (4, "Heading1")] { s.sel = crate::Selection::caret(Pos::body(i, 0)); run(&mut s, "para.style", json!({"style": style})); } run(&mut s, "view.outline", json!({})); s } fn outline_shown(s: &mut Session) -> Vec { let st = run(s, "outline.state", json!({})); st["shown"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() } /// The body blocks the outline layout draws. fn laid_out_blocks(s: &mut Session) -> Vec { let l = s.layout(); let mut v: Vec = l.pages[0] .items .iter() .filter_map(|it| match it { wordcraft_layout::Placed::Lines { story: StoryRef::Body, path, .. } => path.0.first().copied(), _ => None, }) .collect(); v.dedup(); v } #[test] fn outline_level_changes_are_undoable() { let mut s = outline_doc(); let level = |s: &mut Session, i: usize| run(s, "outline.state", json!({}))["levels"][i].as_u64().unwrap(); s.sel = crate::Selection::caret(Pos::body(1, 0)); run(&mut s, "outline.level", json!({"level": 3})); assert_eq!(level(&mut s, 1), 3); run(&mut s, "edit.undo", json!({})); assert_eq!(level(&mut s, 1), 10, "body text again"); s.sel = crate::Selection::caret(Pos::body(2, 0)); run(&mut s, "outline.promoteToHeading1", json!({})); assert_eq!(level(&mut s, 2), 1); run(&mut s, "outline.demoteToBody", json!({})); assert_eq!(level(&mut s, 2), 10); assert!(s.run("outline.level", &json!({"level": 12})).is_err()); assert!(s.run("outline.level", &json!({})).is_err()); // Every outline command survives junk parameters in Outline view. for spec in cmd::registry().all().iter().filter(|c| c.id.starts_with("outline.")) { for j in [json!(null), json!({"block": 99999, "level": "x", "value": 5}), json!({"block": -1, "level": 0})] { let mut s = outline_doc(); let _ = s.run(spec.id, &j); let _ = s.layout(); } } } #[test] fn outline_show_level_filters_paragraphs() { let mut s = outline_doc(); assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]); assert_eq!(laid_out_blocks(&mut s), vec![0, 1, 2, 3, 4, 5]); run(&mut s, "outline.showLevel", json!({"level": 1})); assert_eq!(outline_shown(&mut s), vec![0, 4]); assert_eq!(laid_out_blocks(&mut s), vec![0, 4]); run(&mut s, "outline.showLevel2", json!({})); assert_eq!(outline_shown(&mut s), vec![0, 2, 4]); run(&mut s, "outline.showAll", json!({})); assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]); // Word's keys, and only in Outline view. let reg = cmd::registry(); assert_eq!(reg.by_shortcut("Alt+Shift+1").map(|c| c.id), Some("outline.showLevel1")); assert_eq!(reg.by_shortcut("Alt+Shift+A").map(|c| c.id), Some("outline.showAll")); assert_eq!(reg.by_shortcut("Alt+Shift+=").map(|c| c.id), Some("outline.expand")); assert_eq!(reg.by_shortcut("Alt+Shift+-").map(|c| c.id), Some("outline.collapse")); run(&mut s, "outline.close", json!({})); assert!(s.run("outline.showLevel1", &json!({})).is_err()); } #[test] fn outline_collapse_hides_descendants_and_expand_restores() { let mut s = outline_doc(); s.sel = crate::Selection::caret(Pos::body(0, 0)); // The deepest open level closes first, then the heading itself. run(&mut s, "outline.collapse", json!({})); assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 4, 5]); run(&mut s, "outline.collapse", json!({})); assert_eq!(outline_shown(&mut s), vec![0, 4, 5]); assert_eq!(laid_out_blocks(&mut s), vec![0, 4, 5]); // A caret moving into the collapsed text goes on to the next shown paragraph. run(&mut s, "caret.end", json!({})); run(&mut s, "caret.right", json!({})); assert_eq!(s.sel.focus, Pos::body(4, 0)); // Moving "Three" and "c" up passes "One" with everything collapsed under it, in one undo step. s.sel = crate::Selection { anchor: Pos::body(4, 0), focus: Pos::body(5, 1) }; run(&mut s, "outline.moveUp", json!({})); assert_eq!(text(&s), "Three\nc\nOne\na\nTwo\nb"); assert_eq!(outline_shown(&mut s), vec![0, 1, 2], "the collapsed heading moved and stays collapsed"); run(&mut s, "edit.undo", json!({})); assert_eq!(text(&s), "One\na\nTwo\nb\nThree\nc"); assert_eq!(outline_shown(&mut s), vec![0, 4, 5]); // Expand opens one level at a time; double-clicking the symbol (toggle) opens it all. s.sel = crate::Selection::caret(Pos::body(0, 0)); run(&mut s, "outline.expand", json!({})); assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 4, 5]); run(&mut s, "outline.expand", json!({})); assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]); run(&mut s, "outline.toggle", json!({"block": 0})); assert_eq!(outline_shown(&mut s), vec![0, 4, 5]); run(&mut s, "outline.toggle", json!({"block": 0})); assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]); // Clicking a symbol selects the heading with what is under it. run(&mut s, "outline.selectSubtree", json!({"block": 0})); assert_eq!(s.selected_text(), "One\na\nTwo\nb"); } /// #745: layout keeps a picture's size, so pictures read from HTML, Markdown, ODT or RTF at /// their pixel size are fitted to the text width when the file is opened (and when pasted), /// keeping their proportions; small pictures keep their size. #[cfg(not(target_arch = "wasm32"))] #[test] fn imported_pictures_fit_the_text_width() { let png = |w: u32, h: u32| { let mut b = Vec::new(); image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])) .write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png) .unwrap(); cmd::insert::base64_encode(&b) }; let sizes = |doc: &wordcraft_doc::Document| -> Vec<(f32, f32)> { doc.body .iter() .filter_map(|b| b.as_para()) .flat_map(|p| p.objects.iter()) .filter_map(|o| match o { wordcraft_doc::InlineObject::Image { w, h, .. } => Some((*w, *h)), _ => None, }) .collect() }; let md = format!("![wide](data:image/png;base64,{})\n\n![small](data:image/png;base64,{})\n", png(1200, 300), png(40, 20)); let doc = crate::io::open_bytes("p.md", md.as_bytes()).unwrap(); let max_w = crate::io::text_width(&doc); let got = sizes(&doc); assert_eq!(got.len(), 2, "{got:?}"); assert!((got[0].0 - max_w).abs() < 0.01, "wide picture fits: {got:?} (text width {max_w})"); assert!((got[0].1 - max_w / 4.0).abs() < 0.01, "and keeps its proportions: {got:?}"); assert!((got[1].0 - 30.0).abs() < 0.01 && (got[1].1 - 15.0).abs() < 0.01, "small picture keeps its size: {got:?}"); // Pasting HTML fits our text width too. let mut s = Session::new(wordcraft_doc::Document::new()); let html = format!("

", png(1200, 300)); s.run("edit.pasteSpecial", &json!({"as": "html", "html": html})).unwrap(); let max_w = crate::io::text_width(&s.doc); let got = sizes(&s.doc); assert!(got.iter().any(|(w, _)| (w - max_w).abs() < 0.01), "pasted picture fits: {got:?}"); } #[test] fn format_font_sets_the_asian_and_latin_fonts_separately() { // #505: Word's Font dialog sets an Asian text font and a Latin text font of their own. let mut s = s(); run(&mut s, "document.setText", json!({"text": "Hello 你好"})); run(&mut s, "select.all", json!({})); let fonts = |s: &Session| { let c = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0).clone(); (c.font, c.font_ea) }; let before = fonts(&s); run(&mut s, "format.font", json!({"name": "Georgia", "eastAsia": "SimSun"})); assert_eq!(fonts(&s), (Some("Georgia".into()), Some("SimSun".into()))); assert_eq!(run(&mut s, "format.state", json!({}))["fontEastAsia"], "SimSun"); // The Asian font alone leaves the Latin font as it is. run(&mut s, "format.font", json!({"eastAsia": "Noto Sans CJK SC"})); assert_eq!(fonts(&s), (Some("Georgia".into()), Some("Noto Sans CJK SC".into()))); // Each change is one undo step. run(&mut s, "edit.undo", json!({})); assert_eq!(fonts(&s), (Some("Georgia".into()), Some("SimSun".into()))); run(&mut s, "edit.undo", json!({})); assert_eq!(fonts(&s), before); assert!(s.run("format.font", &json!({})).is_err() && s.run("format.font", &json!({"eastAsia": " "})).is_err()); } /// The result of the formula field in cell `c` of row `r` of the first table, if there is one. fn formula_result(s: &Session, r: usize, c: usize) -> Option { let t = s.doc.body.iter().find_map(|b| b.as_table())?; t.rows.get(r)?.cells.get(c)?.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).find_map(|o| match o { wordcraft_doc::InlineObject::Field { instr, result, .. } if instr.starts_with('=') => Some(result.clone()), _ => None, }) } /// A 4 × 4 matrix (values 1 to 9 under the header) with an empty fifth row; the caret in its /// second cell. fn formula_table() -> Session { let mut s = s(); run(&mut s, "table.quick", json!({"kind": "matrix"})); run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 3, 1, 0], "off": 0}})); run(&mut s, "table.insertRowBelow", json!({})); run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 4, 1, 0], "off": 0}})); s } #[test] fn table_formula_reads_cell_references_and_functions() { let mut s = formula_table(); let r = run(&mut s, "table.formula", json!({"formula": "=B2+B3*2"})); assert_eq!(r["result"], "9"); assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("9")); let mut s = formula_table(); assert_eq!(run(&mut s, "table.formula", json!({"formula": "=IF(SUM(ABOVE)>10, 1, 0)"}))["result"], "1"); let mut s = formula_table(); assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(B2:C3) \\# \"0.00\""}))["result"], "12.00"); } #[test] fn table_formula_with_an_unknown_function_is_an_error_and_inserts_nothing() { let mut s = formula_table(); let err = s.run("table.formula", &json!({"formula": "=MEDIAN(ABOVE)"})).err().map(|e| e.to_string()).unwrap_or_default(); assert!(err.contains("MEDIAN"), "{err}"); assert_eq!(formula_result(&s, 4, 1), None); assert!(s.run("table.formula", &json!({"formula": "=SUM()"})).is_err(), "empty parentheses are a syntax error, as in Word"); } #[test] fn table_formula_bare_function_name_takes_its_direction_from_the_cell() { let mut s = formula_table(); let r = run(&mut s, "table.formula", json!({"formula": "=SUM"})); assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some("=SUM(ABOVE)"), Some("12"))); } /// Update Field (F9) works a formula out again from the cells it reads. #[test] fn update_fields_recalculates_a_table_formula() { let mut s = formula_table(); run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"})); assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("12")); // Row 1's 1 becomes 10. run( &mut s, "select.range", json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}), ); run(&mut s, "text.insert", json!({"text": "10"})); run(&mut s, "references.updateFields", json!({})); assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("21")); } /// A one-column table (no header row) holding `cells`, in a document whose default language /// is `lang`; the caret ends in the last cell. fn column_table(lang: &str, cells: &[&str]) -> Session { let mut s = s(); run(&mut s, "review.language", json!({"lang": lang, "default": true})); run(&mut s, "insert.table", json!({"rows": cells.len(), "cols": 1})); for (k, c) in cells.iter().enumerate() { if k > 0 { run(&mut s, "text.tab", json!({})); } if !c.is_empty() { run(&mut s, "text.insert", json!({"text": c})); } } s } fn column_texts(s: &Session) -> Vec { let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap(); t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::()).collect() } /// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish. #[test] fn table_sort_reads_decimal_comma_in_polish() { let mut s = column_table("pl-PL", &["12", "1,5", "2"]); run(&mut s, "table.sort", json!({"column": 0, "header": false})); assert_eq!(column_texts(&s), ["1,5", "2", "12"]); run(&mut s, "table.sort", json!({"column": 0, "header": false, "descending": true})); assert_eq!(column_texts(&s), ["12", "2", "1,5"]); // English keeps the comma as a thousands separator. let mut s = column_table("en-US", &["1,250", "12.5", "2"]); run(&mut s, "table.sort", json!({"column": 0, "header": false})); assert_eq!(column_texts(&s), ["2", "12.5", "1,250"]); } /// #836: table formulas read each cell in its language and write the result with its decimal /// separator. #[test] fn table_formula_reads_and_writes_decimal_comma_in_polish() { let mut s = column_table("pl-PL", &["3,50", "2,25", ""]); assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "5,75"); let mut s = column_table("pl-PL", &["1 250,00", "100", ""]); assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1350"); let mut s = column_table("pl-PL", &["1\u{a0}250,50 zł", "100", ""]); assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1350,50"); let mut s = column_table("en-US", &["$1,250.50", "3.25", ""]); assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1253.75"); } /// #837: text sorts follow the language's alphabet: Polish `ł` after `l`, `ć` after `c`. #[test] fn table_and_paragraph_sort_use_polish_alphabet() { let mut s = column_table("pl-PL", &["Zakopane", "Łódź", "Kraków"]); run(&mut s, "table.sort", json!({"column": 0, "header": false})); assert_eq!(column_texts(&s), ["Kraków", "Łódź", "Zakopane"]); let mut s = Session::new(wordcraft_doc::Document::from_text("zebra\nćma\ndom")); run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); run(&mut s, "select.all", json!({})); run(&mut s, "para.sort", json!({})); assert_eq!(text(&s), "ćma\ndom\nzebra"); // Swedish puts å ä ö after z; German sorts ä with a. let mut s = column_table("sv-SE", &["ödla", "zebra", "åka", "apa"]); run(&mut s, "table.sort", json!({"column": 0, "header": false})); assert_eq!(column_texts(&s), ["apa", "zebra", "åka", "ödla"]); let mut s = column_table("de-DE", &["Zug", "Äpfel", "Bach"]); run(&mut s, "table.sort", json!({"column": 0, "header": false})); assert_eq!(column_texts(&s), ["Äpfel", "Bach", "Zug"]); } /// #837: index entries and letter headings follow the language: `Łódź` files under its own /// `Ł` heading between `K` and `Z` in Polish. #[test] fn index_sorts_polish_entries_and_headings() { let mut s = Session::new(wordcraft_doc::Document::from_text("Zakopane.\nŁódź.\nKraków.")); run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); for w in ["Zakopane", "Łódź", "Kraków"] { run(&mut s, "select.text", json!({"text": w})); run(&mut s, "references.markEntry", json!({})); } run(&mut s, "caret.docEnd", json!({})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "references.index", json!({})); let lines: Vec = s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some("ListEntry")).map(|p| p.plain_text()).collect(); let order: Vec<&str> = lines.iter().map(|l| l.split([',', '\t']).next().unwrap_or("")).collect(); let pos = |w: &str| order.iter().position(|x| *x == w).unwrap_or(usize::MAX); assert!(pos("Kraków") < pos("Łódź") && pos("Łódź") < pos("Zakopane"), "{lines:?}"); } /// Review of #844: a numbered column (`1.`, `2.`, `10.`) sorts as numbers; text with no language /// at all follows the editing language (a Polish interface reads `3,50` as 3.5). #[test] fn ordinals_and_untagged_text_sort_and_sum_as_numbers() { let mut t = column_table("pl-PL", &["10.", "2.", "1."]); run(&mut t, "table.sort", json!({"column": 0, "header": false})); assert_eq!(column_texts(&t), ["1.", "2.", "10."]); let mut t = column_table("en-US", &["10", "(50)", "20"]); run(&mut t, "table.sort", json!({"column": 0, "header": false})); assert_eq!(column_texts(&t), ["(50)", "10", "20"]); let mut s = s(); s.set_editing_language(Some("pl-PL")); s.doc.styles.default_chr.lang = None; run(&mut s, "insert.table", json!({"rows": 3, "cols": 1})); run(&mut s, "text.insert", json!({"text": "3,50"})); run(&mut s, "text.tab", json!({})); run(&mut s, "text.insert", json!({"text": "2,25"})); run(&mut s, "text.tab", json!({})); assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "5,75"); } /// Review of #844: the bibliography lists Polish authors in the Polish alphabet. #[test] fn bibliography_sorts_polish_authors() { let mut s = s(); run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); for author in ["Mazur, Jan", "Łukasiewicz, Jan", "Lis, Anna"] { run(&mut s, "references.citation", json!({"source": {"author": author, "title": format!("Książka {author}"), "year": "2020"}})); run(&mut s, "text.insert", json!({"text": " "})); } run(&mut s, "text.newParagraph", json!({})); run(&mut s, "references.bibliography", json!({})); let all = text(&s); // The list, not the citations before it. let t = all.split_once("References").map_or(all.as_str(), |(_, list)| list); let at = |name: &str| t.find(&format!("{name}, ")).unwrap_or_else(|| panic!("{name} in {all}")); assert!(at("Lis") < at("Łukasiewicz") && at("Łukasiewicz") < at("Mazur"), "{t}"); } /// A new blank document takes its paper from the region, and lengths are shown in the region's /// unit with its decimal separator; an explicit Measurement units choice still wins (#838). #[test] fn new_documents_follow_the_region() { // Poland: A4, centimetres, decimal comma. let mut s = s(); run(&mut s, "file.options", json!({"region": "pl-PL"})); run(&mut s, "file.new", json!({})); let sp = &s.doc.last_section; assert_eq!(wordcraft_geom::paper_name(sp.page_w, sp.page_h), Some("A4")); assert_eq!(sp.margin_left, 72.0); assert_eq!(s.units(), wordcraft_geom::Unit::Centimeters); assert_eq!(s.format_length(sp.margin_left), "2,54 cm"); let o = run(&mut s, "file.options", json!({"pane": "advanced", "units": "auto"})); assert_eq!((o["units"].as_str(), o["unitsFromRegion"].as_bool()), (Some("centimeters"), Some(true))); assert_eq!((o["region"]["paper"].as_str(), o["region"]["decimal"].as_str()), (Some("A4"), Some(","))); // The user's own choice wins over the region, and `auto` gives the region's back. run(&mut s, "file.options", json!({"units": "inches"})); assert_eq!(s.units(), wordcraft_geom::Unit::Inches); assert_eq!(s.format_length(108.0), "1,5\""); run(&mut s, "file.options", json!({"units": "auto"})); assert_eq!(s.units(), wordcraft_geom::Unit::Centimeters); // A chosen region is a saved preference over the system's; `auto` returns to the system's. assert_eq!(s.prefs.region.as_deref(), Some("pl-PL")); let saved: serde_json::Value = serde_json::to_value(&s.prefs).unwrap(); assert_eq!(saved["region"], "pl-PL"); s.system_region = wordcraft_geom::Region::from_tag("ja-JP"); assert_eq!(s.region().territory, "PL", "the choice wins over the system"); let o = run(&mut s, "file.options", json!({"region": "auto"})); assert_eq!((o["regionFromSystem"].as_bool(), o["region"]["territory"].as_str()), (Some(true), Some("JP"))); assert_eq!(s.prefs.region, None); run(&mut s, "file.options", json!({"region": "de_AT.UTF-8@euro"})); assert_eq!(s.prefs.region.as_deref(), Some("de-AT"), "kept as a short tag"); run(&mut s, "file.options", json!({"region": ""})); assert_eq!(s.prefs.region, None); // The United States: Letter and inches. let mut us = Session::new(wordcraft_doc::Document::new()); run(&mut us, "file.options", json!({"region": "en-US"})); run(&mut us, "file.new", json!({"template": "blank"})); let sp = &us.doc.last_section; assert_eq!((sp.page_w, sp.page_h), (612.0, 792.0)); assert_eq!(us.units(), wordcraft_geom::Unit::Inches); assert_eq!(us.format_length(72.0), "1\""); run(&mut us, "file.options", json!({"units": "centimeters"})); assert_eq!(us.format_length(72.0), "2.54 cm"); // Templates keep their own size; a headless session is en-US; junk is refused or read as en-US. let mut t = Session::new(wordcraft_doc::Document::new()); run(&mut t, "file.options", json!({"region": "de-DE"})); run(&mut t, "file.new", json!({"template": "letter"})); assert_eq!(t.doc.last_section.page_w, crate::sample::letter().last_section.page_w); assert_eq!(Session::new(wordcraft_doc::Document::new()).blank_document().last_section.page_w, 612.0); assert!(t.run("file.options", &json!({"region": 7})).is_err()); assert!(t.run("file.options", &json!({"units": "furlongs"})).is_err()); run(&mut t, "file.options", json!({"region": "\u{0}\u{FFFF}-zz-ZZZZZZZZ"})); assert_eq!(t.region(), wordcraft_geom::Region::default()); assert_eq!(t.prefs.region.as_deref(), Some("en-US"), "junk is saved as en-US"); } /// Automatic (HTML-style) paragraph spacing: `para.spacing` turns it on and off, and a number /// replaces it, as typing one in Word's Paragraph dialog does; Ctrl+0 and Remove Space After work /// on it too. #[test] fn spacing_commands_handle_automatic_spacing() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Pasted from the web"})); let rp = |s: &Session| s.doc.styles.resolve_para(&s.doc.para_at(&s.sel.focus).unwrap().props); run(&mut s, "para.spacing", json!({"beforeAuto": true, "afterAuto": true})); assert_eq!((rp(&s).space_before, rp(&s).space_after, rp(&s).before_auto), (14.0, 14.0, true)); run(&mut s, "para.spacing", json!({"before": 6})); assert_eq!((rp(&s).space_before, rp(&s).before_auto, rp(&s).after_auto), (6.0, false, true), "a number replaces Auto"); run(&mut s, "para.removeSpaceAfter", json!({})); assert_eq!((rp(&s).space_after, rp(&s).after_auto), (0.0, false)); run(&mut s, "para.spacing", json!({"beforeAuto": true})); run(&mut s, "para.addSpaceBefore", json!({})); assert_eq!((rp(&s).space_before, rp(&s).before_auto), (0.0, false), "Ctrl+0 removes the automatic space"); assert!(s.run("para.spacing", &json!({"beforeAuto": "yes"})).is_ok(), "junk is ignored, never a panic"); } /// #712, as in Word: Ctrl+F (`edit.find` with no text) opens the Navigation pane and asks the UI /// to put the keyboard in its search box, not the dialog; Ctrl+H (`edit.replace` with nothing to /// replace) opens Find and Replace on its Replace tab instead of failing; Go To moves by pages from /// the caret's page (`pageBy`). #[test] fn find_replace_and_go_to_open_like_word() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "cat dog cat"})); run(&mut s, "edit.find", json!({})); assert!(s.view.nav_pane); assert_eq!(s.ui_requests.last(), Some(&json!({"focus": "navSearch"}))); assert!(!s.ui_requests.iter().any(|r| r.get("open").is_some()), "no dialog: {:?}", s.ui_requests); s.dirty = false; run(&mut s, "edit.findReplace", json!({})); assert_eq!(s.ui_requests.last(), Some(&json!({"open": "replace"}))); assert!(!s.dirty, "opening the dialog changes nothing"); assert!(s.run("edit.findReplace", &json!({"tab": "nope"})).is_err()); let reg = s.registry.clone(); assert_eq!(s.keymap.resolve(®, "Mod+H").map(|c| c.id), Some("edit.findReplace")); run(&mut s, "edit.goto", json!({})); assert_eq!(s.ui_requests.last(), Some(&json!({"open": "goto"}))); // Replace with a "with" and the last search still replaces, as before. run(&mut s, "edit.find", json!({"text": "dog"})); run(&mut s, "edit.replace", json!({"with": "cow"})); assert_eq!(text(&s), "cat cow cat"); // Five pages; Go To +2 / -1 from where the caret is, clamped to the document. let mut s = super::tests::s(); for i in 0..5 { if i > 0 { run(&mut s, "insert.pageBreak", json!({})); } run(&mut s, "text.insert", json!({"text": format!("Page {}", i + 1)})); } let page = |s: &mut Session| s.layout().caret_on(&s.sel.focus, s.page_hint).unwrap().page + 1; run(&mut s, "edit.goto", json!({"page": 1})); assert_eq!(page(&mut s), 1); run(&mut s, "edit.goto", json!({"pageBy": 2})); assert_eq!(page(&mut s), 3); run(&mut s, "edit.goto", json!({"pageBy": -1})); assert_eq!(page(&mut s), 2); run(&mut s, "edit.goto", json!({"pageBy": 99})); assert_eq!(page(&mut s), 5, "past the end: the last page"); run(&mut s, "edit.goto", json!({"pageBy": -99})); assert_eq!(page(&mut s), 1, "before the start: the first page"); assert!(s.run("edit.goto", &json!({"pageBy": i64::MIN})).is_ok(), "hostile numbers never panic"); } /// #802: Insert › Picture takes Windows Metafiles (and EMF) at their own frame size, stored under /// their own extension so a save writes them as WMF/EMF parts. #[cfg(not(target_arch = "wasm32"))] #[test] fn insert_picture_takes_a_windows_metafile() { use wordcraft_doc::para::InlineObject; // One WMF record: size in words, function, parameters. let rec = |func: u16, params: &[i16]| { let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec(); v.extend(func.to_le_bytes()); params.iter().for_each(|p| v.extend(p.to_le_bytes())); v }; // A placeable WMF, 1440 × 720 units at 1440 per inch (72 × 36 points), drawing a rectangle. let body = [rec(0x041B, &[720, 1440, 0, 0]), rec(0, &[])].concat(); let mut wmf = 0x9AC6_CDD7u32.to_le_bytes().to_vec(); for v in [0i16, 0, 0, 1440, 720] { wmf.extend(v.to_le_bytes()); } wmf.extend(1440u16.to_le_bytes()); wmf.extend([0u8; 6]); let words = ((wmf.len() + 18 + body.len()) / 2) as u32; for v in [1u16, 9, 0x300] { wmf.extend(v.to_le_bytes()); } wmf.extend(words.to_le_bytes()); wmf.extend(0u16.to_le_bytes()); wmf.extend(5u32.to_le_bytes()); wmf.extend(0u16.to_le_bytes()); wmf.extend(body); let dir = scratch_dir("insert-wmf"); let path = dir.join("logo.wmf"); std::fs::write(&path, &wmf).unwrap(); let mut s = Session::new(wordcraft_doc::Document::new()); let r = run(&mut s, "insert.picture", json!({"path": path.to_string_lossy()})); assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(72.0), Some(36.0)), "{r}"); let key = r["media"].as_str().unwrap().to_string(); assert!(key.ends_with(".wmf"), "{key}"); assert_eq!(s.doc.media.get(&key).map(|b| b.len()), Some(wmf.len())); let mut placed = false; s.doc.objects_in_reading_order(&mut |o| placed |= matches!(o, InlineObject::Image { media, .. } if *media == key)); assert!(placed, "the picture is in the text"); // Neither a picture nor a metafile: the same error as before, naming the formats. std::fs::write(&path, b"not a picture").unwrap(); let err = s.run("insert.picture", &json!({"path": path.to_string_lossy()})).unwrap_err(); assert!(err.to_string().contains("WMF, EMF"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } /// Change Picture must keep the replacement's own format: the part's name and content type /// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word /// and LibreOffice then show a broken picture). #[cfg(not(target_arch = "wasm32"))] #[test] fn picture_change_keeps_the_replacement_format() { let dir = scratch_dir("picture-change-format"); let first = dir.join("first.png"); std::fs::write(&first, tiny_png()).unwrap(); let mut jpeg = Vec::new(); image::RgbImage::from_pixel(2, 2, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); let second = dir.join("second.jpg"); std::fs::write(&second, &jpeg).unwrap(); let mut s = Session::new(wordcraft_doc::Document::new()); run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()})); run(&mut s, "picture.change", json!({"path": second.to_string_lossy()})); let mut media = String::new(); s.doc.objects_in_reading_order(&mut |o| { if let wordcraft_doc::para::InlineObject::Image { media: m, .. } = o { media = m.clone(); } }); assert!(media.ends_with(".jpeg") || media.ends_with(".jpg"), "the JPEG keeps its format: {media}"); let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); assert_eq!(back.media.get(&media).map(|b| b.as_slice()), Some(jpeg.as_slice()), "the saved part is the JPEG"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn json_open_repairs_hostile_run_lengths() { use wordcraft_doc::{Block, Document}; // "héllo": é is two bytes (1..3); a run ending at 2 splits it, usize::MAX overflows a sum. for lens in [vec![2, 4], vec![usize::MAX, usize::MAX], vec![1], vec![], vec![0, 99]] { let mut d = Document::from_text("héllo"); if let Some(Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) { p.runs = lens.iter().map(|&len| wordcraft_doc::para::Run { len, props: Default::default() }).collect(); } let bytes = serde_json::to_vec(&d).unwrap(); let (doc, _) = crate::io::open_bytes_with("x.json", &bytes, None).unwrap(); let Some(Block::Para(p)) = doc.body.first().map(|b| &**b) else { panic!("no paragraph") }; let mut end = 0; for (r, _) in p.run_ranges() { assert!(p.text.is_char_boundary(r.end) && r.end > r.start, "{lens:?}"); end = r.end; } assert_eq!(end, p.text.len(), "{lens:?}"); } } #[test] fn outline_aliases_add_one_undo_step() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "one"})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "two"})); for id in ["outline.promote", "outline.demote", "outline.moveUp", "outline.moveDown"] { let (doc, sel, depth) = (s.doc.clone(), s.sel.clone(), s.undo_depth()); run(&mut s, id, json!({})); assert_ne!(s.doc, doc, "{id} changed nothing"); assert_eq!(s.undo_depth(), depth + 1, "{id}"); run(&mut s, "edit.undo", json!({})); assert_eq!(s.doc, doc, "{id}: one undo restores the document"); assert_eq!(s.sel, sel, "{id}: one undo restores the selection"); run(&mut s, "edit.redo", json!({})); } } /// Insert › Link › Place in This Document (#1234): a link to a bookmark, a heading or the top of /// the document, the list of those places, and following the link. #[test] fn links_to_places_in_this_document() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Intro"})); run(&mut s, "para.style", json!({"style": "Heading1"})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "para.style", json!({"style": "Normal"})); run(&mut s, "text.insert", json!({"text": "Body text here"})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "Details"})); run(&mut s, "para.style", json!({"style": "Heading2"})); run(&mut s, "select.text", json!({"text": "Body"})); run(&mut s, "insert.bookmark", json!({"name": "Mark1"})); let links = |s: &Session| -> Vec<(String, String)> { s.doc .body .iter() .filter_map(|b| b.as_para()) .flat_map(|p| p.run_ranges().filter_map(|(r, c)| Some((c.link.clone()?, p.text.get(r)?.to_string()))).collect::>()) .collect() }; // The places: the top, both headings with their levels, and the one visible bookmark. let t = run(&mut s, "link.targets", json!({})); assert_eq!(t["top"], true); let heads: Vec<(u64, String)> = t["headings"].as_array().unwrap().iter().map(|h| (h["level"].as_u64().unwrap(), h["text"].as_str().unwrap().to_string())).collect(); assert_eq!(heads, vec![(1, "Intro".to_string()), (2, "Details".to_string())]); assert_eq!(t["bookmarks"], json!(["Mark1"])); // A bookmark link on the selection. run(&mut s, "select.text", json!({"text": "here"})); run(&mut s, "insert.link", json!({"bookmark": "Mark1"})); assert_eq!(links(&s), vec![("#Mark1".to_string(), "here".to_string())]); // A heading link at a caret inserts the heading's text; it reuses the heading's hidden bookmark. run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); run(&mut s, "insert.link", json!({"heading": t["headings"][1]["block"]})); let l = links(&s); assert_eq!(l.len(), 2, "{l:?}"); let (hidden, shown) = l.iter().find(|(a, _)| a.starts_with("#_Toc")).cloned().unwrap(); assert_eq!(shown, "Details", "{l:?}"); let t2 = run(&mut s, "link.targets", json!({})); assert_eq!(t2["bookmarks"], json!(["Mark1"]), "the hidden bookmark is not listed"); run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); run(&mut s, "insert.link", json!({"heading": t["headings"][1]["block"], "text": "again"})); assert!(links(&s).iter().all(|(a, _)| a == "#Mark1" || *a == hidden), "one bookmark for the heading: {:?}", links(&s)); // The top of the document, and following the links. run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); run(&mut s, "insert.link", json!({"top": true})); assert!(links(&s).iter().any(|(a, t)| a == "#_top" && t == "Top of the Document"), "{:?}", links(&s)); run(&mut s, "edit.goto", json!({"bookmark": "Mark1"})); assert_ne!(s.sel.focus, Pos::body(0, 0)); run(&mut s, "edit.goto", json!({"bookmark": "_top"})); assert_eq!(s.sel.focus, Pos::body(0, 0)); // Refusals change nothing. let rev = s.rev(); assert!(s.run("insert.link", &json!({"bookmark": "Nope"})).is_err()); assert!(s.run("insert.link", &json!({"heading": 1})).is_err(), "a body paragraph is not a heading"); assert_eq!(s.rev(), rev); } /// A DOCX built in code: a tracked deletion inside a field result, as Word writes when a /// formula field's result is edited (#1037). The first field's result is split across the field /// end; the second has its whole result deleted. fn tracked_field_result_docx() -> Vec { use std::io::Write; const WHO: &str = r#"w:author="Tester" w:date="2026-10-12T00:00:00Z""#; let field = |instr: &str, inside: &str, after: &str| { format!( r#" {instr} {inside}{after}"# ) }; let del = |id: u32, t: &str| format!(r#"{t}"#); let ins = |id: u32, t: &str| format!(r#"{t}"#); let p1 = field("=SUM(ABOVE)", &del(1, "$"), &(del(2, "150.00") + &ins(3, "$140.00"))); let p2 = field("=2+3", &del(4, "5"), &ins(5, "five")); let xml = format!( r#"{p1}{p2}"# ); let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for (name, value) in [ ("word/document.xml", xml.as_str()), ( "_rels/.rels", r#""#, ), ] { zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); zip.write_all(value.as_bytes()).unwrap(); } zip.finish().unwrap().into_inner() } #[test] fn tracked_deletion_inside_a_field_result_is_a_revision_and_is_saved() { let doc = wordcraft_docx::read(&tracked_field_result_docx()).unwrap(); // Revisions with one author and date are shared: one deletion, one insertion. let kinds: Vec<_> = doc.revisions.iter().map(|r| r.kind).collect(); assert_eq!(kinds, [wordcraft_doc::RevisionKind::Delete, wordcraft_doc::RevisionKind::Insert]); // The deleted `$` is still in the text, marked deleted (not ordinary result text). let p = doc.body[0].as_para().unwrap(); assert_eq!(p.text.replace(wordcraft_doc::para::OBJ, ""), "$150.00$140.00"); // Saving keeps the deletion inside the result, with its text as delText. let bytes = wordcraft_docx::write(&doc).unwrap(); let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut xml = String::new(); std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); let sep = xml.find(r#"w:fldCharType="separate""#).unwrap(); let end = xml.find(r#"w:fldCharType="end""#).unwrap(); let inside = &xml[sep..end]; assert!(inside.contains(" Vec { body_texts(s).iter().map(|t| t.replace(wordcraft_doc::para::OBJ, "")).collect() } #[test] fn accept_and_reject_all_handle_a_tracked_deletion_inside_a_field_result() { let open = || Session::new(wordcraft_docx::read(&tracked_field_result_docx()).unwrap()); let mut s = open(); run(&mut s, "review.acceptAll", json!({})); assert_eq!(visible_texts(&s), ["$140.00", "five"]); assert!(s.doc.revisions.is_empty()); let mut s = open(); run(&mut s, "review.rejectAll", json!({})); assert_eq!(visible_texts(&s), ["$150.00", "5"]); } #[test] fn rename_document_preserves_saved_contents_and_rejects_collisions() { let dir = std::env::temp_dir().join(format!("wordcraft-rename-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let original = dir.join("original.docm"); let renamed = dir.join("Notes.docm"); let existing = dir.join("existing.docm"); std::fs::write(&original, b"original bytes").unwrap(); std::fs::write(&existing, b"keep these bytes").unwrap(); let mut s = Session::new(wordcraft_doc::Document::new()); s.path = Some(original.clone()); s.dirty = true; assert!(s.run("file.rename", &json!({"name": "existing"})).is_err()); assert_eq!(s.path.as_ref(), Some(&original)); assert_eq!(std::fs::read(&existing).unwrap(), b"keep these bytes"); let roots = crate::file_access::AutomationRoots::new(Some(&dir), None).unwrap(); assert!(crate::file_access::confine(roots.as_ref(), || s.run("file.rename", &json!({"name": "Notes"}))).is_err()); assert!(original.exists()); assert!(!renamed.exists()); s.run("file.rename", &json!({"name": " Notes "})).unwrap(); assert_eq!(s.path.as_ref(), Some(&renamed)); assert!(s.dirty); assert!(!original.exists()); assert_eq!(std::fs::read(&renamed).unwrap(), b"original bytes"); s.run("file.rename", &json!({"name": "Notes"})).unwrap(); s.run("file.rename", &json!({"name": "notes"})).unwrap(); let lower = dir.join("notes.docm"); assert_eq!(s.path.as_ref(), Some(&lower)); let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); assert!(names.contains(&"notes.docm".into()) && !names.contains(&"Notes.docm".into()), "{names:?}"); assert_eq!(std::fs::read(&lower).unwrap(), b"original bytes"); assert!(s.run("file.rename", &json!({"name": "existing"})).is_err()); assert_eq!(s.path.as_ref(), Some(&lower)); assert_eq!(std::fs::read(&existing).unwrap(), b"keep these bytes"); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn rename_unsaved_document_names_the_file_not_the_document() { let mut s = Session::new(wordcraft_doc::Document::new()); s.doc.core.title = "Quarterly report".into(); for name in ["", " ", "../report", "a/b", "a\\b", "a\nb", "name."] { assert!(s.run("file.rename", &json!({"name": name})).is_err(), "{name:?}"); assert!(s.file_name.is_none()); } s.run("file.rename", &json!({"name": " Résumé notes "})).unwrap(); assert_eq!(s.file_name.as_deref(), Some("Résumé notes")); assert_eq!(s.doc.core.title, "Quarterly report"); assert!(s.path.is_none()); assert!(!s.dirty); assert!(!s.can_undo()); s.run("file.new", &json!({})).unwrap(); assert!(s.file_name.is_none()); s.run("file.rename", &json!({"name": "Draft"})).unwrap(); s.run("file.open", &json!({"path": "other.txt", "data": "aGk="})).unwrap(); assert!(s.file_name.is_none()); s.run("file.new", &json!({})).unwrap(); s.run("file.rename", &json!({"name": "Draft"})).unwrap(); s.run("review.compare", &json!({"text": "changed", "showIn": "new"})).unwrap(); assert!(s.file_name.is_none() && s.path.is_none()); } /// #1344: File › Info › Inspect Document finds hidden text by its effective formatting (direct, /// character style, paragraph style) in every story — body, header, footer, footnote, comment, /// text box — and Remove All deletes exactly that text, in one undo step, through a DOCX save. #[test] fn inspect_document_hidden_text_in_every_story_and_from_styles() { use wordcraft_doc::props::{CharProps, ParaProps}; use wordcraft_doc::styles::{Style, StyleKind}; use wordcraft_doc::{Paragraph, PartKind, para_block}; let mut s = s(); run(&mut s, "text.insert", json!({"text": "Body words"})); run(&mut s, "review.newComment", json!({"text": "c"})); s.sel = crate::Selection::caret(Pos::body(0, 0)); run(&mut s, "insert.textBox", json!({"text": "t"})); s.sel = crate::Selection::caret(Pos::body(0, 0)); run(&mut s, "references.footnote", json!({"text": "n"})); s.sel = crate::Selection::caret(Pos::body(0, 0)); run(&mut s, "references.endnote", json!({"text": "e"})); s.sel = crate::Selection::caret(Pos::body(0, 0)); run(&mut s, "insert.header", json!({"text": "h"})); s.sel = crate::Selection::caret(Pos::body(0, 0)); run(&mut s, "insert.footer", json!({"text": "f"})); s.sel = crate::Selection::caret(Pos::body(0, 0)); let hidden = CharProps { hidden: Some(true), ..Default::default() }; s.doc.styles.upsert(Style { id: "HideC".into(), name: "Hide C".into(), kind: StyleKind::Character, chr: hidden.clone(), ..Default::default() }); s.doc.styles.upsert(Style { id: "HideP".into(), name: "Hide P".into(), kind: StyleKind::Paragraph, based_on: Some("Normal".into()), chr: hidden.clone(), ..Default::default() }); let plain = CharProps::default(); let blocks = |tag: &str| { let mut a = Paragraph::with_text(&format!("keep-{tag}-1 "), plain.clone()); for (t, c) in [ (format!("hide-{tag}-direct"), hidden.clone()), (format!(" keep-{tag}-2 "), plain.clone()), (format!("hide-{tag}-cstyle"), CharProps { style: Some("HideC".into()), ..Default::default() }), (format!(" keep-{tag}-3"), plain.clone()), ] { let at = a.len(); a.insert_text(at, &t, &c).unwrap(); } let mut b = Paragraph::with_text(&format!("hide-{tag}-pstyle"), plain.clone()); b.props = ParaProps { style: Some("HideP".into()), ..Default::default() }; // Shown again by direct formatting over the hidden paragraph style: it stays. let mut c = Paragraph::with_text(&format!("keep-{tag}-4"), CharProps { hidden: Some(false), ..Default::default() }); c.props = ParaProps { style: Some("HideP".into()), ..Default::default() }; vec![para_block(a), para_block(b), para_block(c)] }; let tag = |k: PartKind| match k { PartKind::Header => "header", PartKind::Footer => "footer", PartKind::Footnote => "footnote", PartKind::Endnote => "endnote", PartKind::Comment => "comment", PartKind::TextBox => "textBox", }; s.doc.body.extend(blocks("body")); // A table cell in the body, with the same pieces. let mut table = wordcraft_doc::table::Table::new(1, 2, 200.0); table.rows[0].cells[0].blocks = blocks("cell"); s.doc.body.push(std::sync::Arc::new(wordcraft_doc::Block::Table(table))); s.doc.body.push(para_block(Paragraph::new())); let mut tags = vec![("body", "body"), ("cell", "body")]; for part in s.doc.parts.values_mut() { part.blocks.extend(blocks(tag(part.kind))); tags.push((tag(part.kind), tag(part.kind))); } tags.sort(); assert_eq!(tags.iter().map(|t| t.0).collect::>(), ["body", "cell", "comment", "endnote", "footer", "footnote", "header", "textBox"]); let stories = |d: &wordcraft_doc::Document| std::iter::once(StoryRef::Body).chain(d.parts.keys().map(|k| StoryRef::Part(*k))).collect::>(); let para_counts = |d: &wordcraft_doc::Document| stories(d).into_iter().map(|st| (st, d.para_paths(st).len())).collect::>(); let counts_before = para_counts(&s.doc); let all_text = |d: &wordcraft_doc::Document| -> String { std::iter::once(StoryRef::Body).chain(d.parts.keys().map(|k| StoryRef::Part(*k))).map(|st| d.plain_text(st)).collect::>().join("\n") }; // Inspect reports every hidden piece, in its story. let found = run(&mut s, "file.inspect", json!({}))["found"].clone(); assert_eq!(found["hiddenText"], true); let items = found["hidden"].as_array().cloned().unwrap_or_default(); for (t, story) in &tags { for how in ["direct", "cstyle", "pstyle"] { let want = format!("hide-{t}-{how}"); assert!( items.iter().any(|i| i["story"] == *story && i["text"].as_str().is_some_and(|x| x.contains(&want))), "{want} not reported: {items:?}" ); } } assert!(!items.iter().any(|i| i["text"].as_str().is_some_and(|x| x.contains("keep-"))), "visible text reported: {items:?}"); // Remove All: every hidden piece goes, every visible one stays, in one undo step. let before = all_text(&s.doc); let undo_len = s.undo_labels().len(); let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); assert_eq!(out["residual"], json!([]), "{out}"); let after = all_text(&s.doc); for (t, _) in &tags { for n in 1..=4 { assert!(after.contains(&format!("keep-{t}-{n}")), "keep-{t}-{n} lost:\n{after}"); } } assert!(!after.contains("hide-"), "hidden text left:\n{after}"); // The paragraphs hidden through their style went, marks and all (the body lost two: its own // and the table cell's); the paragraph shown again by direct formatting stayed. let expected: Vec<_> = counts_before.iter().map(|(st, n)| (*st, n - if *st == StoryRef::Body { 2 } else { 1 })).collect(); assert_eq!(para_counts(&s.doc), expected); assert_eq!(s.undo_labels().len(), undo_len + 1); run(&mut s, "edit.undo", json!({})); assert_eq!(all_text(&s.doc), before, "one undo brings it all back"); run(&mut s, "edit.redo", json!({})); assert_eq!(all_text(&s.doc), after); assert_eq!(run(&mut s, "file.inspect", json!({}))["found"]["hiddenText"], false); // Save and reopen: none of the hidden strings is in the file. let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); let reopened = all_text(&back); assert!(!reopened.contains("hide-"), "hidden text in the saved file:\n{reopened}"); assert!(reopened.contains("keep-header-4") && reopened.contains("keep-comment-3"), "{reopened}"); assert!(reopened.contains("keep-endnote-2") && reopened.contains("keep-cell-4"), "{reopened}"); } fn hidden_props() -> wordcraft_doc::props::CharProps { wordcraft_doc::props::CharProps { hidden: Some(true), ..Default::default() } } /// #1344: document defaults that hide text hide it everywhere they aren't overridden. #[test] fn inspect_document_removes_text_hidden_by_document_defaults() { use wordcraft_doc::props::CharProps; use wordcraft_doc::{Paragraph, para_block}; let mut s = s(); s.doc.styles.default_chr.hidden = Some(true); s.doc.body = vec![ para_block(Paragraph::with_text("hide-default", CharProps::default())), para_block(Paragraph::with_text("keep-default", CharProps { hidden: Some(false), ..Default::default() })), ]; let found = run(&mut s, "file.inspect", json!({}))["found"].clone(); assert!(found["hidden"].as_array().unwrap().iter().any(|i| i["text"] == "hide-default"), "{found}"); let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); assert_eq!(out["residual"], json!([]), "{out}"); assert_eq!(text(&s), "keep-default"); assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1); } /// #1344: a hidden piece holding a content control's end keeps the marker (its start is outside). #[test] fn inspect_document_keeps_content_control_markers() { use wordcraft_doc::para::InlineObject; use wordcraft_doc::props::CharProps; use wordcraft_doc::{Paragraph, para_block}; let mut s = s(); let mut p = Paragraph::with_text("keep-a ", CharProps::default()); let plain = CharProps::default(); p.insert_object(p.len(), InlineObject::ControlStart { control: Box::default() }, &plain).unwrap(); p.insert_text(p.len(), "keep-cc ", &plain).unwrap(); p.insert_text(p.len(), "hide-cc", &hidden_props()).unwrap(); p.insert_object(p.len(), InlineObject::ControlEnd, &hidden_props()).unwrap(); p.insert_text(p.len(), "hide-after", &hidden_props()).unwrap(); p.insert_text(p.len(), " keep-b", &plain).unwrap(); s.doc.body = vec![para_block(p)]; let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); assert_eq!(out["residual"], json!([]), "{out}"); let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); assert_eq!(p.text.replace('\u{FFFC}', ""), "keep-a keep-cc keep-b"); let objs: Vec<_> = p.object_offsets().into_iter().filter_map(|o| p.object_at(o)).collect(); assert!(matches!(objs.as_slice(), [InlineObject::ControlStart { .. }, InlineObject::ControlEnd]), "{objs:?}"); } /// #1344: text hidden by a table style isn't resolved by the inspector, so it says so: the table /// is listed as unsupported and the removal's residual isn't empty. #[test] fn inspect_document_lists_tables_whose_style_hides_text() { use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts}; let mut s = s(); s.doc.styles.upsert(Style { id: "HideHeader".into(), name: "Hide Header".into(), kind: StyleKind::Table, table: Some(TableStyleParts { header_chr: hidden_props(), ..Default::default() }), ..Default::default() }); let mut table = wordcraft_doc::table::Table::new(2, 2, 200.0); table.props.style = Some("HideHeader".into()); s.doc.body.insert(0, std::sync::Arc::new(wordcraft_doc::Block::Table(table))); let found = run(&mut s, "file.inspect", json!({}))["found"].clone(); let unsupported = found["unsupported"].as_array().cloned().unwrap_or_default(); assert!(unsupported.iter().any(|u| u["object"] == "table" && u["story"] == "body" && u["style"] == "HideHeader"), "{found}"); let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); assert!(out["residual"].as_array().is_some_and(|r| r.iter().any(|u| u["object"] == "table")), "{out}"); } /// #1344: removing a hidden footnote reference removes its note too, as deleting a reference does, /// and reports the note's text. #[test] fn inspect_document_removes_the_note_of_a_hidden_reference() { use wordcraft_doc::PartKind; let mut s = s(); run(&mut s, "text.insert", json!({"text": "Body"})); let id = run(&mut s, "references.footnote", json!({"text": "note-visible"}))["id"].as_u64().unwrap() as u32; let path = wordcraft_doc::Path::top(0); let p = s.doc.para_mut(StoryRef::Body, &path).unwrap(); let off = p.object_offsets()[0]; p.format(off, off + '\u{FFFC}'.len_utf8(), &|c| c.hidden = Some(true)).unwrap(); let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); assert_eq!(out["residual"], json!([]), "{out}"); assert!(!s.doc.parts.contains_key(&id), "orphan note part left"); let notes = out["removedNotes"].as_array().cloned().unwrap_or_default(); assert!(notes.iter().any(|n| n["kind"] == "footnote" && n["text"].as_str().is_some_and(|t| t.contains("note-visible"))), "{out}"); assert_eq!(text(&s), "Body"); let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); assert!(!back.parts.values().any(|p| p.kind == PartKind::Footnote), "note in the saved file"); assert_eq!(back.plain_text(StoryRef::Body), "Body"); } /// #1344: many hidden pieces (index entries are hidden text) are removed quickly, and the /// found list is capped. #[test] fn inspect_document_removes_thousands_of_hidden_pieces() { use wordcraft_doc::props::CharProps; use wordcraft_doc::{Paragraph, para_block}; let mut s = s(); s.doc.body = (0..2000) .map(|i| { let mut p = Paragraph::with_text(&format!("word{i} "), CharProps::default()); p.insert_text(p.len(), &format!("XE \"entry{i}\""), &hidden_props()).unwrap(); p.insert_text(p.len(), " more", &CharProps::default()).unwrap(); para_block(p) }) .collect(); let t = std::time::Instant::now(); let found = run(&mut s, "file.inspect", json!({}))["found"].clone(); assert_eq!(found["hiddenCount"], 2000); assert_eq!(found["hidden"].as_array().map(Vec::len), Some(1000)); assert_eq!(found["truncated"], true); let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); assert_eq!(out["residual"], json!([])); assert!(!text(&s).contains("XE")); assert!(text(&s).contains("word1999 more")); assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); } /// #1344: a hidden paragraph between two tables stays (emptied, its mark still hidden), or the /// tables would join into one when the file is reopened. #[test] fn inspect_document_keeps_a_hidden_paragraph_between_tables() { use wordcraft_doc::{Block, Paragraph, para_block}; let mut s = s(); let table = || std::sync::Arc::new(Block::Table(wordcraft_doc::table::Table::new(1, 1, 100.0))); let mut p = Paragraph::with_text("hide-between", hidden_props()); p.mark.hidden = Some(true); s.doc.body = vec![table(), para_block(p), table(), para_block(Paragraph::with_text("keep-end", Default::default()))]; let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); assert_eq!(out["residual"], json!([]), "{out}"); let kinds = |d: &wordcraft_doc::Document| d.body.iter().map(|b| matches!(**b, Block::Table(_))).collect::>(); assert_eq!(kinds(&s.doc), [true, false, true, false]); let kept = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); assert!(kept.is_empty() && kept.mark.hidden == Some(true), "emptied, mark still hidden: {kept:?}"); assert!(!s.doc.plain_text(StoryRef::Body).contains("hide-")); assert_eq!(run(&mut s, "file.inspect", json!({}))["found"]["hiddenText"], false); let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); assert_eq!(kinds(&back).iter().filter(|t| **t).count(), 2, "the tables stay apart"); } /// #1381: the reporter's sample HTML (synthetic, 1.7 KB). const SAMPLE_1381: &str = r##"Testo iniziale

Premessa

Il presente documento descrive l’organizzazione della struttura sanitaria di prova e le misure adottate per la gestione del rischio clinico. I contenuti sono sintetici e servono solo a verificare la resa della conversione.

Le attività sono svolte secondo le raccomandazioni ministeriali vigenti e sono oggetto di revisione annuale da parte del comitato per la valutazione dei sinistri.

  • Identificazione dei rischi per reparto
  • Analisi degli eventi avversi e dei near miss
    • segnalazione spontanea
    • revisione delle cartelle cliniche
  • Piano annuale di miglioramento
  1. Raccolta delle segnalazioni
  2. Classificazione dell’evento
  3. Azioni correttive
IndicatoreValore attesoValore rilevato
Segnalazioni analizzate entro 30 giorni≥ 90%93%
Audit clinici svolti45
Totale azioni correttive aperte12

Fonte: elaborazione interna su dati sintetici.

"##; /// The first table's grid width in points. fn first_table_width(doc: &wordcraft_doc::Document) -> f32 { doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.grid.iter().sum::()) } else { None }).unwrap() } #[test] fn opened_html_text_and_markdown_get_the_region_paper() { // #1381: a file that gives no page size opens on the region's paper, as a new blank document // does (#838): A4 in Italy, Letter in the US. Tables fit that page. let dir = std::env::temp_dir().join(format!("wordcraft-region-paper-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let html = dir.join("S05.html"); std::fs::write(&html, SAMPLE_1381).unwrap(); for (tag, size) in [("it-IT", (595.28, 841.89)), ("en-US", (612.0, 792.0))] { let mut s = s(); s.system_region = wordcraft_geom::Region::from_tag(tag); // By path (pictures load from beside the file) and by bytes (the web, MCP). run(&mut s, "file.open", json!({"path": html.to_string_lossy()})); let sp = s.doc.last_section.clone(); assert_eq!((sp.page_w, sp.page_h), size, "{tag}: HTML by path"); assert!(first_table_width(&s.doc) <= sp.text_width() + 0.5, "{tag}: the table fits the page"); for (name, body) in [("S05.html", SAMPLE_1381), ("notes.txt", "Hello"), ("notes.md", "# Title\n\n|a|b|\n|-|-|\n|1|2|\n")] { let data = crate::cmd::insert::base64_encode(body.as_bytes()); run(&mut s, "file.open", json!({"path": name, "data": data})); let sp = &s.doc.last_section; assert_eq!((sp.page_w, sp.page_h), size, "{tag}: {name}"); } } let _ = std::fs::remove_dir_all(&dir); } #[test] fn opened_documents_with_their_own_page_size_keep_it() { // #1381: only files without a page size take the region's paper. for (w, h) in [(500.0, 700.0), (612.0, 792.0)] { let mut doc = wordcraft_doc::Document::from_text("Own size"); doc.last_section.page_w = w; doc.last_section.page_h = h; for name in ["own.docx", "own.rtf", "own.odt"] { let bytes = crate::io::save_bytes(name, &doc).unwrap(); let mut s = s(); s.system_region = wordcraft_geom::Region::from_tag("it-IT"); run(&mut s, "file.open", json!({"path": name, "data": crate::cmd::insert::base64_encode(&bytes)})); let sp = &s.doc.last_section; assert_eq!((sp.page_w.round(), sp.page_h.round()), (w, h), "{name}"); } } } #[test] fn html_converted_to_docx_keeps_bold_headers_and_right_aligned_cells() { // #1381: `wordcraft-cli convert S05.html out.docx`: the runs are bold and the // `text-align:right` cells' paragraphs carry `w:jc w:val="right"`. use std::io::Read; let mut s = s(); run(&mut s, "file.open", json!({"path": "S05.html", "data": crate::cmd::insert::base64_encode(SAMPLE_1381.as_bytes())})); let bytes = crate::io::save_bytes("out.docx", &s.doc).unwrap(); let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); let mut xml = String::new(); zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); let cell = |text: &str| { let at = xml.find(&format!(">{text}<")).unwrap_or_else(|| panic!("{text} in {xml}")); let start = xml[..at].rfind("").or_else(|| xml[..at].rfind("").unwrap(); xml[start..end].to_string() }; for head in ["Indicatore", "Valore atteso", "Valore rilevato"] { assert!(cell(head).contains(""), "{head}: {}", cell(head)); } for right in ["93%", "4", "5", "12"] { assert!(cell(right).contains(r#""#), "{right}: {}", cell(right)); } let left = cell("Audit clinici svolti"); assert!(!left.contains(r#""#) && !left.contains(""), "{left}"); } #[test] fn exported_page_images_draw_headers_at_full_strength() { // file.exportPng (and `wordcraft-cli render`) drew headers and footers dimmed, as the screen // does while the body is edited; Word's exported pages and WordCraft's PDF don't. let mut s = s(); run(&mut s, "text.insert", json!({"text": "Body"})); let mut hp = wordcraft_doc::Paragraph::with_text("HEADER", wordcraft_doc::props::CharProps { size: Some(28.0), ..Default::default() }); hp.props.align = Some(wordcraft_doc::props::Align::Left); let id = s.doc.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); s.doc.last_section.headers.default = Some(id); let dir = std::env::temp_dir().join(format!("wordcraft-png-header-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("p.png"); s.run("file.exportPng", &json!({"path": path.to_string_lossy(), "scale": 1.0})).unwrap(); let img = image::open(&path).unwrap().to_luma8(); // The darkest pixel in the top tenth of the page (the header) is black, not grey. let top = img.height() / 10; let darkest = (0..top).flat_map(|y| (0..img.width()).map(move |x| (x, y))).map(|(x, y)| img.get_pixel(x, y)[0]).min().unwrap(); assert!(darkest < 60, "header drawn dimmed: darkest pixel {darkest}"); let _ = std::fs::remove_dir_all(&dir); } /// Text typed right after a footnote or endnote number is plain body text, not another raised number. #[test] fn typing_after_a_note_number_is_not_superscript() { for (cmd_id, style) in [("references.footnote", "FootnoteReference"), ("references.endnote", "EndnoteReference")] { let mut s = s(); run(&mut s, "text.insert", json!({"text": "One"})); run(&mut s, cmd_id, json!({"text": "Note"})); s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); assert_eq!(s.doc.para_at(&s.sel.focus).and_then(|p| p.props_at(p.len()).style.clone()).as_deref(), Some(style)); assert_eq!(s.typing_props().style, None, "{cmd_id}"); run(&mut s, "text.insert", json!({"text": " after"})); let end = s.doc.end_of(StoryRef::Body); assert_eq!(s.doc.para_at(&end).map(|p| p.props_of_char(p.len() - 1).style.clone()), Some(None), "{cmd_id}"); } } /// Gap 19, review: text inserted by one author and deleted by another keeps both marks through /// an ODT save, so Reject All (undo the deletion, then the insertion) and Accept All both remove it. #[test] fn odt_inserted_then_deleted_text_round_trips() { use std::io::Write; let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#; let info = |who: &str| format!("{who}2024-03-01T10:00:00"); let content = format!( r#"{}{}goneKeep this."#, info("Alice"), info("Bob") ); let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] { z.start_file(name, o).unwrap(); z.write_all(data.as_bytes()).unwrap(); } let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); let doc = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap(); for cmd in ["review.rejectAll", "review.acceptAll"] { let mut s = Session::new(doc.clone()); run(&mut s, cmd, json!({})); assert_eq!(text(&s), "Keep this.", "{cmd}"); } } /// Gap 19: an ODT's tracked insertion and multi-paragraph deletion come in as revisions that /// Reject All turns back into the original and Accept All into the edited text. #[test] fn odt_tracked_changes_accept_and_reject_all() { use std::io::Write; let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#; let info = |who: &str| format!("{who}2024-03-01T10:00:00"); let content = format!( r#"{}{}tailheadFirst rest added.End."#, info("Alice"), info("Bob") ); let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] { z.start_file(name, o).unwrap(); z.write_all(data.as_bytes()).unwrap(); } let bytes = z.finish().unwrap().into_inner(); let doc = crate::io::open_bytes("t.odt", &bytes).unwrap(); let mut sr = Session::new(doc.clone()); run(&mut sr, "review.rejectAll", json!({})); assert_eq!(text(&sr), "First tail\nhead rest .\nEnd."); assert!(sr.doc.revisions.is_empty() || run(&mut sr, "review.changes", json!({})).as_array().unwrap().is_empty()); let mut sa = Session::new(doc); run(&mut sa, "review.acceptAll", json!({})); assert_eq!(text(&sa), "First rest added.\nEnd."); assert!(run(&mut sa, "review.changes", json!({})).as_array().unwrap().is_empty()); } /// Gap 19, review 2: a paragraph break deleted inside an insertion range is not itself /// inserted, so Reject All keeps the two paragraphs (after an ODT save, too). #[test] fn odt_deleted_mark_inside_an_insertion_survives_reject_all() { use std::io::Write; let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#; let info = |who: &str| format!("{who}2024-03-01T10:00:00"); let content = format!( r#"{}{}One AC two"#, info("Alice"), info("Bob") ); let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] { z.start_file(name, o).unwrap(); z.write_all(data.as_bytes()).unwrap(); } let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); let saved = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap(); for d in [doc, saved] { let mut s = Session::new(d); run(&mut s, "review.rejectAll", json!({})); assert_eq!(text(&s), "One \n two"); } } /// #1320: `layout.verticalAlign` sets the section's vertical alignment, Justified included /// ("justify", or "both"/"justified"); a value it doesn't know is Top, as before. Section JSON /// for `layout.pageSetup` keeps its names. #[test] fn vertical_align_command_sets_justified() { use wordcraft_doc::section::PageVAlign; let mut s = s(); let va = |s: &Session| crate::cmd::page::sect(s).valign; for (value, want) in [ ("justify", PageVAlign::Justify), ("center", PageVAlign::Center), ("both", PageVAlign::Justify), ("bottom", PageVAlign::Bottom), ("justified", PageVAlign::Justify), ("top", PageVAlign::Top), ("sideways", PageVAlign::Top), ] { run(&mut s, "layout.verticalAlign", json!({"value": value})); assert_eq!(va(&s), want, "{value}"); } run(&mut s, "layout.verticalAlign", json!({"value": "justify"})); run(&mut s, "edit.undo", json!({})); assert_eq!(va(&s), PageVAlign::Top, "one Undo step"); let spec = s.registry.get("layout.verticalAlign").unwrap(); assert!(spec.params.contains("justify"), "documents its values: {}", spec.params); // Section JSON: today's names still read, and Justified is "justify". let mut props = serde_json::to_value(crate::cmd::page::sect(&s)).unwrap(); assert_eq!(props["valign"], json!("top")); for (name, want) in [("center", PageVAlign::Center), ("bottom", PageVAlign::Bottom), ("justify", PageVAlign::Justify), ("top", PageVAlign::Top)] { props["valign"] = json!(name); run(&mut s, "layout.pageSetup", json!({"section": props.clone()})); assert_eq!(va(&s), want, "{name}"); assert_eq!(serde_json::to_value(crate::cmd::page::sect(&s)).unwrap()["valign"], json!(name)); } } /// #1876 review: only Web Layout wraps to the window's `web_width`. Draft and Outline keep a fixed /// 800 pt column, also after a visit to a wide Web Layout, and the window width is never serialized. #[test] fn draft_keeps_its_column_after_web_layout() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Draft text wraps in a fixed column. ".repeat(80)})); run(&mut s, "view.draft", json!({})); let before = s.layout(); assert_eq!(before.pages[0].w, 800.0); run(&mut s, "view.webLayout", json!({})); s.view.web_width = 1400.0; assert_eq!(s.layout().pages[0].w, 1400.0, "Web Layout follows the window"); run(&mut s, "view.draft", json!({})); assert_eq!(s.layout().pages[0].w, 800.0, "Draft never sees Web Layout's window width"); run(&mut s, "view.outline", json!({})); assert_eq!(s.layout().pages[0].w, 800.0, "nor does Outline"); let serialized = serde_json::to_value(&s.view).unwrap(); assert!(serialized.get("webWidth").is_none(), "a window width is not view state to keep"); let restored: crate::ViewState = serde_json::from_value(serialized).unwrap(); assert_eq!(restored.web_width, 800.0); } /// Insert › Quick Parts and AutoText list, insert and save the document's own building blocks /// (its glossary, kept in the file); saving with `"in": "document"` is undoable. #[test] fn quick_parts_use_the_documents_building_blocks() { use wordcraft_doc::{BuildingBlock, CharProps, Paragraph, para_block}; let entry = |name: &str, gallery: &str, text: &str| BuildingBlock { name: name.into(), gallery: gallery.into(), category: "General".into(), blocks: vec![para_block(Paragraph::with_text(text, CharProps::default()))], ..Default::default() }; let mut d = wordcraft_doc::Document::new(); d.glossary = vec![entry("Signature", "docParts", "Kind regards, Ann"), entry("Disclaimer", "autoTxt", "No warranty.")]; let bytes = wordcraft_docx::write(&d).unwrap(); let mut s = Session::new(crate::io::open_bytes("glossary.docx", &bytes).unwrap()); let listed = run(&mut s, "insert.quickParts", json!({})); assert_eq!(listed["document"], json!(["Signature"]), "{listed}"); assert_eq!(listed["parts"], json!(["Signature"]), "{listed}"); assert_eq!(run(&mut s, "insert.autoText", json!({}))["document"], json!(["Disclaimer"])); run(&mut s, "insert.quickParts", json!({"insert": "Signature"})); assert!(text(&s).contains("Kind regards, Ann"), "{}", text(&s)); run(&mut s, "insert.autoText", json!({"insert": "Disclaimer"})); assert!(text(&s).contains("No warranty."), "{}", text(&s)); // Saved into the document: undoable, and kept in its file. run(&mut s, "select.text", json!({"text": "Kind regards"})); s.dirty = false; let listed = run(&mut s, "insert.quickParts", json!({"save": "Closing", "in": "document"})); assert_eq!(listed["document"], json!(["Signature", "Closing"]), "{listed}"); assert!(s.building_blocks.is_empty(), "not in the session's store"); assert!(s.dirty); run(&mut s, "edit.undo", json!({})); assert!(!s.doc.glossary.iter().any(|e| e.name == "Closing")); run(&mut s, "edit.redo", json!({})); let back = crate::io::open_bytes("glossary.docx", &wordcraft_docx::write(&s.doc).unwrap()).unwrap(); let closing = back.glossary.iter().find(|e| e.name == "Closing").expect("saved entry"); assert_eq!(closing.gallery, "docParts"); assert_eq!(closing.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.as_str()).collect::(), "Kind regards"); assert_eq!(back.glossary.len(), 3); // Without "in", saving stays in the session; deleting removes the name where it lives. run(&mut s, "insert.quickParts", json!({"save": "Scratch"})); assert!(s.building_blocks.contains_key("Scratch") && !s.doc.glossary.iter().any(|e| e.name == "Scratch")); run(&mut s, "insert.quickParts", json!({"delete": "Scratch"})); run(&mut s, "insert.quickParts", json!({"delete": "Closing"})); assert!(s.building_blocks.is_empty()); assert_eq!(s.doc.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Signature", "Disclaimer"]); assert!(s.run("insert.quickParts", &json!({"save": " ", "in": "document"})).is_err()); assert!(s.run("insert.quickParts", &json!({"insert": "Closing"})).is_err()); } #[test] fn toc_collects_cell_headings() { use std::sync::Arc; use wordcraft_doc::{Block, CharProps, InlineObject, Paragraph, Path, Table, para_block}; let mut earlier = Table::new(1, 1, 180.0); earlier.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Earlier", CharProps::default()).styled("Heading1"))]; let mut nested = Table::new(1, 1, 180.0); nested.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Nested", CharProps::default()).styled("Heading3"))]; let mut later = Paragraph::with_text("Later", CharProps::default()); later.props.outline_level = Some(1); let mut table = Table::new(1, 1, 180.0); table.rows[0].cells[0].blocks = vec![para_block(later), Arc::new(Block::Table(nested)), para_block(Paragraph::with_text("Unlisted", CharProps::default()))]; let mut toc = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading"); toc.insert_object( toc.len(), InlineObject::Field { instr: r#"TOC \o "1-3" \h \u"#.into(), result: String::new(), locked: false, dirty: true, code: false, form: String::new(), }, &CharProps::default(), ) .unwrap(); let mut d = wordcraft_doc::Document::new(); d.body = vec![ Arc::new(Block::Table(earlier)), para_block(toc), para_block(Paragraph::with_text("\u{000C}", CharProps::default())), Arc::new(Block::Table(table)), ]; let mut s = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap()); for command in ["references.updateFields", "references.updateToc"] { run(&mut s, command, json!({})); let entries: Vec<_> = s .doc .body .iter() .filter_map(|b| b.as_para()) .filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading")) .cloned() .collect(); assert_eq!(entries.len(), 3, "{command}: {}", text(&s)); let layout = s.layout(); let bookmarks = s.doc.bookmarks(); let mut pages = Vec::new(); for (p, title) in entries.iter().zip(["Earlier", "Later", "Nested"]) { let value = p.plain_text(); assert!(value.starts_with(title), "{value}"); let shown: u32 = value.rsplit_once('\t').unwrap().1.parse().unwrap(); let name = p.props_of_char(0).link.as_ref().unwrap().strip_prefix('#').unwrap(); let (_, target) = bookmarks.iter().find(|(n, _)| n == name).unwrap(); assert!(target.path.depth() > 0); let paragraph = s.doc.para(StoryRef::Body, &target.path).unwrap(); assert_eq!(paragraph.plain_text(), title); let caret = layout.caret(target).unwrap(); assert_eq!(shown, layout.pages[caret.page].number, "{title}"); pages.push(caret.page); } assert!(pages[1] > pages[0]); assert_eq!(bookmarks.iter().filter(|(n, _)| n.starts_with("_Toc")).count(), 3); // The block-index-only link API must not misidentify a cell paragraph as a body block. let targets = s.run("link.targets", &json!({})).unwrap(); assert_eq!(targets["headings"].as_array().unwrap().len(), 0); assert!(s.doc.para(StoryRef::Body, &Path::top(1)).is_some()); } } #[test] fn toc_keeps_direct_number_tabs() { use wordcraft_doc::props::{CharProps, NumRef, TabAlign, TabLeader, TabStop}; let left = TabStop { pos: 24.0, align: TabAlign::Left, leader: TabLeader::None }; let right = TabStop { pos: 179.5, align: TabAlign::Right, leader: TabLeader::Dot }; let mut d = wordcraft_doc::Document::from_text("Contents\n\n1.\tAlpha\t9\nAlpha\nBeta"); d.last_section.page_w = 220.0; d.last_section.margin_left = 20.0; d.last_section.margin_right = 20.0; d.styles.get_mut("TOC2").unwrap().para.tabs = Some(vec![right]); let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered); { let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); p.props.style = Some("TOCHeading".into()); p.insert_object( p.len(), wordcraft_doc::InlineObject::Field { instr: r#"TOC \o "1-2""#.into(), result: String::new(), locked: false, dirty: true, code: false, form: String::new(), }, &CharProps::default(), ) .unwrap(); let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); p.props.style = Some("TOC2".into()); p.props.tabs = Some(vec![right]); let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2)).unwrap(); p.props.style = Some("TOC2".into()); p.props.tabs = Some(vec![left, right]); } for i in [3, 4] { let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap(); p.props.style = Some("Heading2".into()); p.props.numbering = Some(NumRef { num, level: 0 }); } let bytes = wordcraft_docx::write(&d).unwrap(); let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap()); for command in ["references.updateFields", "references.updateToc", "references.toc"] { run(&mut s, command, json!({})); let layout = s.layout(); let mut entries = 0; for (i, b) in s.doc.body.iter().enumerate() { let Some(p) = b.as_para().filter(|p| p.props.style.as_deref() == Some("TOC2")) else { continue }; entries += 1; assert!(p.props.tabs.as_ref().is_some_and(|tabs| tabs.contains(&left)), "{command}: {:?}", p.props.tabs); let title = p.text.find('\t').unwrap() + 1; let page = p.text.rfind('\t').unwrap() + 1; let start = layout.caret(&Pos::body(i, title)).unwrap(); let end = layout.caret(&Pos::body(i, page)).unwrap(); assert_eq!((start.page, start.top), (end.page, end.top), "{command}: {:?}", p.plain_text()); assert!((start.x - 44.0).abs() < 1.0, "{command}: {}", start.x); assert!(end.x > 180.0, "{command}: {}", end.x); } assert_eq!(entries, 2); } } /// #2601: Insert › Drop Cap's menu and Drop Cap Options. Dropped and In margin take the /// documented defaults (3 lines, distance 0, the text's font); the dialog's values (lines, /// distance, font) are kept and clamped; None takes the drop cap away with its font; each is one /// undo step and the caret stays where it was. The command is off where Word disables it: an /// empty paragraph, a table cell, a paragraph that starts with a space. #[test] fn drop_cap_options_set_position_lines_distance_and_font() { let props = |s: &Session| s.doc.body.first().and_then(|b| b.as_para()).map(|p| p.props.clone()).unwrap_or_default(); let cap = |s: &Session| { let p = props(s); (p.drop_cap, p.drop_cap_margin, p.drop_cap_distance, p.drop_cap_font) }; let mut s = s(); assert!(s.run("insert.dropCap", &json!({})).is_err(), "off in an empty paragraph"); run(&mut s, "text.insert", json!({"text": "Once upon a time"})); let caret = s.sel.clone(); run(&mut s, "insert.dropCap", json!({"position": "margin"})); assert_eq!(cap(&s), (Some(3), Some(true), None, None)); assert_eq!(s.sel, caret, "the caret stays"); run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.2, "font": "Liberation Mono"})); assert_eq!(cap(&s), (Some(5), None, Some(7.2), Some("Liberation Mono".to_string()))); run(&mut s, "edit.undo", json!({})); assert_eq!(cap(&s), (Some(3), Some(true), None, None), "one undo step"); run(&mut s, "insert.dropCap", json!({"lines": 99, "distance": 1e30, "font": " "})); assert_eq!(cap(&s), (Some(10), None, Some(wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE), None), "clamped"); run(&mut s, "insert.dropCap", json!({"lines": -4, "distance": -3, "position": "sideways"})); assert_eq!(cap(&s), (Some(1), None, None, None), "junk falls back to the nearest valid value"); run(&mut s, "insert.dropCap", json!({"font": "Liberation Mono", "distance": 3})); run(&mut s, "insert.dropCap", json!({"position": "none"})); assert_eq!(cap(&s), (None, None, None, None), "None takes the letter's font back too"); assert!(props(&s).is_empty()); run(&mut s, "insert.dropCap", json!({"lines": 2})); run(&mut s, "insert.dropCap", json!({"lines": 0})); assert_eq!(cap(&s), (None, None, None, None), "0 lines is None, as before"); let mut s = self::s(); run(&mut s, "text.insert", json!({"text": " leading space"})); assert!(s.run("insert.dropCap", &json!({})).is_err(), "off when the paragraph starts with a space"); let mut s = self::s(); run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); run(&mut s, "text.insert", json!({"text": "Cell"})); assert!(s.sel.focus.path.cell().is_some()); assert!(s.run("insert.dropCap", &json!({})).is_err(), "off in a table cell"); } #[test] fn rtf_list_round_trip_keeps_text() { for cmd in ["para.bullets", "para.numbering", "para.multilevel", "para.defineBullet"] { let mut s = Session::new(wordcraft_doc::Document::from_text("first\nsecond")); run(&mut s, "select.all", json!({})); run(&mut s, cmd, json!({})); let bytes = crate::io::save_bytes("x.rtf", &s.doc).unwrap(); let d = crate::io::open_bytes("x.rtf", &bytes).unwrap(); assert_eq!(d.plain_text(StoryRef::Body), "first\nsecond", "{cmd}"); } }